diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..f1933103 --- /dev/null +++ b/.gitignore @@ -0,0 +1,67 @@ +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## Build generated +build/ +DerivedData/ + +## Various settings +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata/ + +## Other +*.moved-aside +*.xccheckout +*.xcscmblueprint + +## Obj-C/Swift specific +*.hmap +*.ipa +*.dSYM.zip +*.dSYM + +## Playgrounds +timeline.xctimeline +playground.xcworkspace + +# Swift Package Manager +# +# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. +# Packages/ +# Package.pins +.build/ + +# CocoaPods +# +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control +# +Pods/ + +# Carthage +# +# Add this line if you want to avoid checking in source code from Carthage dependencies. +# Carthage/Checkouts + +Carthage/Build + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the +# screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://github.com/fastlane/fastlane/blob/master/fastlane/docs/Gitignore.md + +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..02d1bfce --- /dev/null +++ b/.travis.yml @@ -0,0 +1,36 @@ +osx_image: xcode8.3 +language: objective-c + +env: + global: + - PROJECT="CloudCore.xcodeproj" + - EXAMPLE_PORJECT="Example/CloudCoreExample.xcodeproj" + + - IOS_FRAMEWORK_SCHEME="CloudCore-iOS" + - MACOS_FRAMEWORK_SCHEME="CloudCore-macOS" + - EXAMPLE_SCHEME="CloudCoreExample" + matrix: + - DESTINATION="OS=10.3,name=iPhone 7 Plus" SCHEME="$IOS_FRAMEWORK_SCHEME" BUILD_EXAMPLE="YES" POD_LINT="YES" + - DESTINATION="arch=x86_64" SCHEME="$MACOS_FRAMEWORK_SCHEME" BUILD_EXAMPLE="NO" POD_LINT="NO" + +before_install: + - gem install cocoapods --pre --no-rdoc --no-ri --no-document --quiet + - gem install xcpretty-travis-formatter + +script: + - set -o pipefail + + # Build and test in debug + - xcodebuild -project "$PROJECT" -scheme "$SCHEME" -destination "$DESTINATION" -configuration Debug ONLY_ACTIVE_ARCH=NO ENABLE_TESTABILITY=YES test | xcpretty -f `xcpretty-travis-formatter` + # Build and test in release + - xcodebuild -project "$PROJECT" -scheme "$SCHEME" -destination "$DESTINATION" -configuration Release ONLY_ACTIVE_ARCH=NO ENABLE_TESTABILITY=YES test | xcpretty -f `xcpretty-travis-formatter` + + # Build example project + - if [ $BUILD_EXAMPLE == "YES" ]; then + xcodebuild -project "$EXAMPLE_PROJECT" -scheme "$EXAMPLE_SCHEME" -destination "$DESTINATION" -configuration Debug ONLY_ACTIVE_ARCH=NO build | xcpretty -f `xcpretty-travis-formatter`; + fi + + # Run `pod lib lint` if specified + - if [ $POD_LINT == "YES" ]; then + pod lib lint; + fi diff --git a/CloudCore.podspec b/CloudCore.podspec new file mode 100755 index 00000000..23a1ebb4 --- /dev/null +++ b/CloudCore.podspec @@ -0,0 +1,25 @@ +Pod::Spec.new do |s| + s.name = "CloudCore" + s.summary = "Framework that enables syncing between iCloud (CloudKit) and Core Data" + s.version = "0.1" + s.homepage = "https://github.com/sorix/CloudCore" + s.license = 'MIT' + s.author = { "Vasily Ulianov" => "vasily@me.com" } + s.source = { + :git => "https://github.com/sorix/CloudCore.git", + :tag => s.version.to_s + } + + s.ios.deployment_target = '10.0' + s.osx.deployment_target = '10.12' + + s.ios.source_files = 'Sources/**/*.swift' + # s.tvos.source_files = 'Sources/**/*.swift' + s.osx.source_files = 'Sources/**/*.swift' + + s.ios.frameworks = 'Foundation', 'CloudKit', 'CoreData' + s.osx.frameworks = 'Foundation', 'CloudKit', 'CoreData' + + s.pod_target_xcconfig = { 'SWIFT_VERSION' => '3.0' } + s.documentation_url = 'https://github.com/Sorix/CloudCore/wiki' +end diff --git a/CloudCore.xcodeproj/project.pbxproj b/CloudCore.xcodeproj/project.pbxproj new file mode 100755 index 00000000..dc14bf9f --- /dev/null +++ b/CloudCore.xcodeproj/project.pbxproj @@ -0,0 +1,1079 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 46; + objects = { + +/* Begin PBXBuildFile section */ + E200D44D1E48E13200B707D4 /* CloudCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E200D44C1E48E13200B707D4 /* CloudCore.swift */; }; + E2075FF91E4BBEAC00E31F1F /* AsynchronousOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2075FF81E4BBEAC00E31F1F /* AsynchronousOperation.swift */; }; + E2075FFF1E4BCD7E00E31F1F /* ObjectToRecordOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2075FFE1E4BCD7E00E31F1F /* ObjectToRecordOperation.swift */; }; + E20A73CC1E68608100A6851A /* RecordToCoreDataOperationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E20A73CB1E68608100A6851A /* RecordToCoreDataOperationTests.swift */; }; + E21FA03E1E4A7E7200B1DAA2 /* RecordToCoreDataOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E21FA03D1E4A7E7200B1DAA2 /* RecordToCoreDataOperation.swift */; }; + E22A53DA1E4A8743009286C0 /* CloudKitAttribute.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22A53D91E4A8743009286C0 /* CloudKitAttribute.swift */; }; + E22C40461E42956C009469A1 /* CoreDataListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22C40451E42956C009469A1 /* CoreDataListener.swift */; }; + E23C478C1E48A404004310F9 /* CloudSaveOperationQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = E23C478B1E48A404004310F9 /* CloudSaveOperationQueue.swift */; }; + E23C47901E48A587004310F9 /* CloudCoreErrorDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E23C478F1E48A587004310F9 /* CloudCoreErrorDelegate.swift */; }; + E23C47921E48B210004310F9 /* NotificationName.swift in Sources */ = {isa = PBXBuildFile; fileRef = E23C47911E48B210004310F9 /* NotificationName.swift */; }; + E23C47941E48CE9C004310F9 /* CloudCoreErrorDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E23C478F1E48A587004310F9 /* CloudCoreErrorDelegate.swift */; }; + E23C47951E48CE9C004310F9 /* CoreDataRelationship.swift in Sources */ = {isa = PBXBuildFile; fileRef = E24F44A51E4595B900F78819 /* CoreDataRelationship.swift */; }; + E23C47971E48CE9C004310F9 /* NotificationName.swift in Sources */ = {isa = PBXBuildFile; fileRef = E23C47911E48B210004310F9 /* NotificationName.swift */; }; + E23C47981E48CE9C004310F9 /* CloudSaveOperationQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = E23C478B1E48A404004310F9 /* CloudSaveOperationQueue.swift */; }; + E247EF8D1E67775500EBD75E /* ErrorBlockProxyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E247EF8B1E67773F00EBD75E /* ErrorBlockProxyTests.swift */; }; + E247EF971E67873E00EBD75E /* DeleteFromCoreDataOperationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E247EF951E67873900EBD75E /* DeleteFromCoreDataOperationTests.swift */; }; + E247EF9A1E678EAC00EBD75E /* CustomFunctions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E247EF981E678EA200EBD75E /* CustomFunctions.swift */; }; + E24F44A61E4595B900F78819 /* CoreDataRelationship.swift in Sources */ = {isa = PBXBuildFile; fileRef = E24F44A51E4595B900F78819 /* CoreDataRelationship.swift */; }; + E2564BFF1E5061BC002E518B /* ErrorBlockProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2564BFE1E5061BC002E518B /* ErrorBlockProxy.swift */; }; + E277DB071E7726FB00DC334A /* PublicDatabaseSubscriptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E277DB061E7726FB00DC334A /* PublicDatabaseSubscriptions.swift */; }; + E277DB081E7726FB00DC334A /* PublicDatabaseSubscriptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E277DB061E7726FB00DC334A /* PublicDatabaseSubscriptions.swift */; }; + E277DB0D1E77F96400DC334A /* FetchPublicSubscriptionsOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E277DB0C1E77F96400DC334A /* FetchPublicSubscriptionsOperation.swift */; }; + E277DB0E1E77F96400DC334A /* FetchPublicSubscriptionsOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E277DB0C1E77F96400DC334A /* FetchPublicSubscriptionsOperation.swift */; }; + E28F0B8E1E671DFA00BF532A /* CoreDataTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29BB2411E439E040020F5B6 /* CoreDataTestCase.swift */; }; + E28F0B8F1E671DFA00BF532A /* CorrectObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29BB2431E43AEAF0020F5B6 /* CorrectObject.swift */; }; + E28F0B931E671E7400BF532A /* CKRecordTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E28F0B911E671E6500BF532A /* CKRecordTests.swift */; }; + E28F0B9F1E67245A00BF532A /* CKRecordIDTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E28F0B9D1E67245600BF532A /* CKRecordIDTests.swift */; }; + E28F0BA21E67260900BF532A /* NSEntityDescriptionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E28F0BA01E6725E700BF532A /* NSEntityDescriptionTests.swift */; }; + E28F0BA31E67280100BF532A /* NSManagedObjectTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E24F44A81E459E3E00F78819 /* NSManagedObjectTests.swift */; }; + E28FBCE01E43DE6F0081FF3B /* CKRecordID.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29BB21D1E433E050020F5B6 /* CKRecordID.swift */; }; + E28FBCE11E43DE6F0081FF3B /* CKRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29BB2201E4344E80020F5B6 /* CKRecord.swift */; }; + E28FBCE21E43DE6F0081FF3B /* NSManagedObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29BB2221E4346FF0020F5B6 /* NSManagedObject.swift */; }; + E28FBCE31E43DE6F0081FF3B /* CloudCoreError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29BB21B1E43381D0020F5B6 /* CloudCoreError.swift */; }; + E28FBCE41E43DE6F0081FF3B /* CloudCoreConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29BB2191E4334590020F5B6 /* CloudCoreConfig.swift */; }; + E28FBCEA1E43DE760081FF3B /* CoreDataListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22C40451E42956C009469A1 /* CoreDataListener.swift */; }; + E28FBCEC1E43DE760081FF3B /* CoreDataAttribute.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29BB2361E4377F80020F5B6 /* CoreDataAttribute.swift */; }; + E29BB21A1E4334590020F5B6 /* CloudCoreConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29BB2191E4334590020F5B6 /* CloudCoreConfig.swift */; }; + E29BB21C1E43381D0020F5B6 /* CloudCoreError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29BB21B1E43381D0020F5B6 /* CloudCoreError.swift */; }; + E29BB21E1E433E050020F5B6 /* CKRecordID.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29BB21D1E433E050020F5B6 /* CKRecordID.swift */; }; + E29BB2211E4344E80020F5B6 /* CKRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29BB2201E4344E80020F5B6 /* CKRecord.swift */; }; + E29BB2231E4346FF0020F5B6 /* NSManagedObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29BB2221E4346FF0020F5B6 /* NSManagedObject.swift */; }; + E29BB22D1E436F310020F5B6 /* CloudCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D5B2E89F1C3A780C00C0327D /* CloudCore.framework */; }; + E29BB2351E436F720020F5B6 /* model.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = E29BB2331E436F720020F5B6 /* model.xcdatamodeld */; }; + E29BB2371E4377F80020F5B6 /* CoreDataAttribute.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29BB2361E4377F80020F5B6 /* CoreDataAttribute.swift */; }; + E29D117A1E69813F00E3DCBF /* CoreDataAttributeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D11791E69813F00E3DCBF /* CoreDataAttributeTests.swift */; }; + E29D117D1E69A47700E3DCBF /* CoreDataRelationshipTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D117B1E69A44C00E3DCBF /* CoreDataRelationshipTests.swift */; }; + E29D11871E69B30C00E3DCBF /* CloudCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D5C629401C3A7FAA007F7B7C /* CloudCore.framework */; }; + E29D118D1E69B31E00E3DCBF /* AsynchronousOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2075FF81E4BBEAC00E31F1F /* AsynchronousOperation.swift */; }; + E29D118E1E69B31E00E3DCBF /* DeleteFromCoreDataOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C02A181E4CDEF1001B2871 /* DeleteFromCoreDataOperation.swift */; }; + E29D118F1E69B31E00E3DCBF /* FetchDatabaseChangesOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C02A0F1E4CBEBB001B2871 /* FetchDatabaseChangesOperation.swift */; }; + E29D11901E69B31E00E3DCBF /* FetchRecordZoneChangesOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C02A131E4CC2A5001B2871 /* FetchRecordZoneChangesOperation.swift */; }; + E29D11911E69B31E00E3DCBF /* RecordToCoreDataOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E21FA03D1E4A7E7200B1DAA2 /* RecordToCoreDataOperation.swift */; }; + E29D11931E69B32400E3DCBF /* ObjectToRecordConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C02A0D1E4C99AD001B2871 /* ObjectToRecordConverter.swift */; }; + E29D11941E69B32400E3DCBF /* ObjectToRecordOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2075FFE1E4BCD7E00E31F1F /* ObjectToRecordOperation.swift */; }; + E29D11951E69B32900E3DCBF /* CloudCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E200D44C1E48E13200B707D4 /* CloudCore.swift */; }; + E29D11971E69B32900E3DCBF /* ErrorBlockProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2564BFE1E5061BC002E518B /* ErrorBlockProxy.swift */; }; + E29D11981E69B32E00E3DCBF /* FetchResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C3A6D01E4A8EAF009151F3 /* FetchResult.swift */; }; + E29D11991E69B33100E3DCBF /* NSEntityDescription.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2D390071E4A49350019BBCD /* NSEntityDescription.swift */; }; + E29D119A1E69B33400E3DCBF /* CloudKitAttribute.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22A53D91E4A8743009286C0 /* CloudKitAttribute.swift */; }; + E29D119B1E69B33400E3DCBF /* ServiceAttributeName.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2EE20061E4E6DCE0060F769 /* ServiceAttributeName.swift */; }; + E29D119C1E69B33400E3DCBF /* Tokens.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2E296C91E49DA0800E7D6ED /* Tokens.swift */; }; + E29D119D1E69B36700E3DCBF /* ErrorBlockProxyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E247EF8B1E67773F00EBD75E /* ErrorBlockProxyTests.swift */; }; + E29D119E1E69B36700E3DCBF /* DeleteFromCoreDataOperationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E247EF951E67873900EBD75E /* DeleteFromCoreDataOperationTests.swift */; }; + E29D119F1E69B36700E3DCBF /* RecordToCoreDataOperationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E20A73CB1E68608100A6851A /* RecordToCoreDataOperationTests.swift */; }; + E29D11A01E69B36700E3DCBF /* CoreDataAttributeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D11791E69813F00E3DCBF /* CoreDataAttributeTests.swift */; }; + E29D11A11E69B36700E3DCBF /* CoreDataRelationshipTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D117B1E69A44C00E3DCBF /* CoreDataRelationshipTests.swift */; }; + E29D11A21E69B36700E3DCBF /* CKRecordIDTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E28F0B9D1E67245600BF532A /* CKRecordIDTests.swift */; }; + E29D11A31E69B36700E3DCBF /* NSEntityDescriptionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E28F0BA01E6725E700BF532A /* NSEntityDescriptionTests.swift */; }; + E29D11A41E69B36700E3DCBF /* NSManagedObjectTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E24F44A81E459E3E00F78819 /* NSManagedObjectTests.swift */; }; + E29D11A51E69B36700E3DCBF /* CKRecordTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E28F0B911E671E6500BF532A /* CKRecordTests.swift */; }; + E29D11A61E69B36700E3DCBF /* CoreDataTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29BB2411E439E040020F5B6 /* CoreDataTestCase.swift */; }; + E29D11A71E69B36700E3DCBF /* CorrectObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29BB2431E43AEAF0020F5B6 /* CorrectObject.swift */; }; + E29D11A81E69B36700E3DCBF /* CustomFunctions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E247EF981E678EA200EBD75E /* CustomFunctions.swift */; }; + E29D11A91E69B3B200E3DCBF /* model.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = E29BB2331E436F720020F5B6 /* model.xcdatamodeld */; }; + E2A3F9451E69B6EC007A65EB /* ObjectToRecordOperationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A3F9441E69B6EC007A65EB /* ObjectToRecordOperationTests.swift */; }; + E2A3F9461E69B6EC007A65EB /* ObjectToRecordOperationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A3F9441E69B6EC007A65EB /* ObjectToRecordOperationTests.swift */; }; + E2BB748E1E7EA8690048C129 /* SetupOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2BB748D1E7EA8690048C129 /* SetupOperation.swift */; }; + E2BB748F1E7EB2B00048C129 /* SetupOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2BB748D1E7EA8690048C129 /* SetupOperation.swift */; }; + E2C02A0E1E4C99AD001B2871 /* ObjectToRecordConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C02A0D1E4C99AD001B2871 /* ObjectToRecordConverter.swift */; }; + E2C02A101E4CBEBB001B2871 /* FetchDatabaseChangesOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C02A0F1E4CBEBB001B2871 /* FetchDatabaseChangesOperation.swift */; }; + E2C02A141E4CC2A5001B2871 /* FetchRecordZoneChangesOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C02A131E4CC2A5001B2871 /* FetchRecordZoneChangesOperation.swift */; }; + E2C02A191E4CDEF1001B2871 /* DeleteFromCoreDataOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C02A181E4CDEF1001B2871 /* DeleteFromCoreDataOperation.swift */; }; + E2C3A6D11E4A8EAF009151F3 /* FetchResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C3A6D01E4A8EAF009151F3 /* FetchResult.swift */; }; + E2D390081E4A49350019BBCD /* NSEntityDescription.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2D390071E4A49350019BBCD /* NSEntityDescription.swift */; }; + E2E296CA1E49DA0800E7D6ED /* Tokens.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2E296C91E49DA0800E7D6ED /* Tokens.swift */; }; + E2E4D8401E76D5A500550CBE /* FetchAndSaveOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2E4D83D1E76D4EF00550CBE /* FetchAndSaveOperation.swift */; }; + E2E4D8411E76D5A600550CBE /* FetchAndSaveOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2E4D83D1E76D4EF00550CBE /* FetchAndSaveOperation.swift */; }; + E2EE20071E4E6DCE0060F769 /* ServiceAttributeName.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2EE20061E4E6DCE0060F769 /* ServiceAttributeName.swift */; }; + E2FA74441E769BF900C3489D /* RecordWithDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FA74431E769BF900C3489D /* RecordWithDatabase.swift */; }; + E2FA74451E769BF900C3489D /* RecordWithDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FA74431E769BF900C3489D /* RecordWithDatabase.swift */; }; + E2FA74481E769D9400C3489D /* RecordIDWithDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FA74471E769D9400C3489D /* RecordIDWithDatabase.swift */; }; + E2FA74491E769D9400C3489D /* RecordIDWithDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FA74471E769D9400C3489D /* RecordIDWithDatabase.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + E29BB22E1E436F310020F5B6 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = D5B2E8961C3A780C00C0327D /* Project object */; + proxyType = 1; + remoteGlobalIDString = D5B2E89E1C3A780C00C0327D; + remoteInfo = "CloudCore-iOS"; + }; + E29D11881E69B30C00E3DCBF /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = D5B2E8961C3A780C00C0327D /* Project object */; + proxyType = 1; + remoteGlobalIDString = D5C6293F1C3A7FAA007F7B7C; + remoteInfo = "CloudCore-Mac"; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + D5B2E89F1C3A780C00C0327D /* CloudCore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = CloudCore.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + D5C629401C3A7FAA007F7B7C /* CloudCore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = CloudCore.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + D5C6298B1C3A8BBD007F7B7C /* Info-iOS.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "Info-iOS.plist"; sourceTree = ""; }; + D5C6298C1C3A8BBD007F7B7C /* Info-Mac.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "Info-Mac.plist"; sourceTree = ""; }; + E200D44C1E48E13200B707D4 /* CloudCore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CloudCore.swift; sourceTree = ""; }; + E2075FF81E4BBEAC00E31F1F /* AsynchronousOperation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AsynchronousOperation.swift; sourceTree = ""; }; + E2075FFE1E4BCD7E00E31F1F /* ObjectToRecordOperation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ObjectToRecordOperation.swift; sourceTree = ""; }; + E20A73CB1E68608100A6851A /* RecordToCoreDataOperationTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RecordToCoreDataOperationTests.swift; sourceTree = ""; }; + E21FA03D1E4A7E7200B1DAA2 /* RecordToCoreDataOperation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RecordToCoreDataOperation.swift; sourceTree = ""; }; + E22A53D91E4A8743009286C0 /* CloudKitAttribute.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CloudKitAttribute.swift; sourceTree = ""; }; + E22C40441E4291FB009469A1 /* CloudCore.podspec */ = {isa = PBXFileReference; lastKnownFileType = text; path = CloudCore.podspec; sourceTree = ""; }; + E22C40451E42956C009469A1 /* CoreDataListener.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CoreDataListener.swift; sourceTree = ""; }; + E23C478B1E48A404004310F9 /* CloudSaveOperationQueue.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CloudSaveOperationQueue.swift; sourceTree = ""; }; + E23C478F1E48A587004310F9 /* CloudCoreErrorDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CloudCoreErrorDelegate.swift; sourceTree = ""; }; + E23C47911E48B210004310F9 /* NotificationName.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationName.swift; sourceTree = ""; }; + E247EF8B1E67773F00EBD75E /* ErrorBlockProxyTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ErrorBlockProxyTests.swift; sourceTree = ""; }; + E247EF951E67873900EBD75E /* DeleteFromCoreDataOperationTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeleteFromCoreDataOperationTests.swift; sourceTree = ""; }; + E247EF981E678EA200EBD75E /* CustomFunctions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomFunctions.swift; sourceTree = ""; }; + E24BD55D1E788C2200D092E6 /* Package.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Package.swift; sourceTree = ""; }; + E24F44A51E4595B900F78819 /* CoreDataRelationship.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CoreDataRelationship.swift; sourceTree = ""; }; + E24F44A81E459E3E00F78819 /* NSManagedObjectTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSManagedObjectTests.swift; sourceTree = ""; }; + E2564BFE1E5061BC002E518B /* ErrorBlockProxy.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ErrorBlockProxy.swift; sourceTree = ""; }; + E277DB061E7726FB00DC334A /* PublicDatabaseSubscriptions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PublicDatabaseSubscriptions.swift; sourceTree = ""; }; + E277DB0C1E77F96400DC334A /* FetchPublicSubscriptionsOperation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FetchPublicSubscriptionsOperation.swift; sourceTree = ""; }; + E28F0B911E671E6500BF532A /* CKRecordTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CKRecordTests.swift; sourceTree = ""; }; + E28F0B9D1E67245600BF532A /* CKRecordIDTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CKRecordIDTests.swift; sourceTree = ""; }; + E28F0BA01E6725E700BF532A /* NSEntityDescriptionTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSEntityDescriptionTests.swift; sourceTree = ""; }; + E29BB2191E4334590020F5B6 /* CloudCoreConfig.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CloudCoreConfig.swift; sourceTree = ""; }; + E29BB21B1E43381D0020F5B6 /* CloudCoreError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CloudCoreError.swift; sourceTree = ""; }; + E29BB21D1E433E050020F5B6 /* CKRecordID.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CKRecordID.swift; sourceTree = ""; }; + E29BB2201E4344E80020F5B6 /* CKRecord.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CKRecord.swift; sourceTree = ""; }; + E29BB2221E4346FF0020F5B6 /* NSManagedObject.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSManagedObject.swift; sourceTree = ""; }; + E29BB2281E436F310020F5B6 /* CloudCoreTests-iOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "CloudCoreTests-iOS.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; + E29BB22C1E436F310020F5B6 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + E29BB2341E436F720020F5B6 /* model.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = model.xcdatamodel; sourceTree = ""; }; + E29BB2361E4377F80020F5B6 /* CoreDataAttribute.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CoreDataAttribute.swift; sourceTree = ""; }; + E29BB2411E439E040020F5B6 /* CoreDataTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CoreDataTestCase.swift; sourceTree = ""; }; + E29BB2431E43AEAF0020F5B6 /* CorrectObject.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CorrectObject.swift; sourceTree = ""; }; + E29D11791E69813F00E3DCBF /* CoreDataAttributeTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CoreDataAttributeTests.swift; sourceTree = ""; }; + E29D117B1E69A44C00E3DCBF /* CoreDataRelationshipTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CoreDataRelationshipTests.swift; sourceTree = ""; }; + E29D11821E69B30C00E3DCBF /* CloudCoreTests-macOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "CloudCoreTests-macOS.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; + E2A3F9441E69B6EC007A65EB /* ObjectToRecordOperationTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ObjectToRecordOperationTests.swift; sourceTree = ""; }; + E2BB748D1E7EA8690048C129 /* SetupOperation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SetupOperation.swift; sourceTree = ""; }; + E2C02A0D1E4C99AD001B2871 /* ObjectToRecordConverter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ObjectToRecordConverter.swift; sourceTree = ""; }; + E2C02A0F1E4CBEBB001B2871 /* FetchDatabaseChangesOperation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FetchDatabaseChangesOperation.swift; sourceTree = ""; }; + E2C02A131E4CC2A5001B2871 /* FetchRecordZoneChangesOperation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FetchRecordZoneChangesOperation.swift; sourceTree = ""; }; + E2C02A181E4CDEF1001B2871 /* DeleteFromCoreDataOperation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeleteFromCoreDataOperation.swift; sourceTree = ""; }; + E2C3A6D01E4A8EAF009151F3 /* FetchResult.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FetchResult.swift; sourceTree = ""; }; + E2D390071E4A49350019BBCD /* NSEntityDescription.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSEntityDescription.swift; sourceTree = ""; }; + E2E296C91E49DA0800E7D6ED /* Tokens.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Tokens.swift; sourceTree = ""; }; + E2E4D83D1E76D4EF00550CBE /* FetchAndSaveOperation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FetchAndSaveOperation.swift; sourceTree = ""; }; + E2EE20061E4E6DCE0060F769 /* ServiceAttributeName.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ServiceAttributeName.swift; sourceTree = ""; }; + E2FA74431E769BF900C3489D /* RecordWithDatabase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RecordWithDatabase.swift; sourceTree = ""; }; + E2FA74471E769D9400C3489D /* RecordIDWithDatabase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RecordIDWithDatabase.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + D5B2E89B1C3A780C00C0327D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D5C6293C1C3A7FAA007F7B7C /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + E29BB2251E436F310020F5B6 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + E29BB22D1E436F310020F5B6 /* CloudCore.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + E29D117F1E69B30C00E3DCBF /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + E29D11871E69B30C00E3DCBF /* CloudCore.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + D5B2E8951C3A780C00C0327D = { + isa = PBXGroup; + children = ( + D5B2E8A01C3A780C00C0327D /* Products */, + E2FA744B1E76B7AC00C3489D /* Resources */, + D5C629691C3A809D007F7B7C /* Sources */, + E29BB2291E436F310020F5B6 /* Tests */, + E22C40441E4291FB009469A1 /* CloudCore.podspec */, + E24BD55D1E788C2200D092E6 /* Package.swift */, + ); + sourceTree = ""; + }; + D5B2E8A01C3A780C00C0327D /* Products */ = { + isa = PBXGroup; + children = ( + D5B2E89F1C3A780C00C0327D /* CloudCore.framework */, + D5C629401C3A7FAA007F7B7C /* CloudCore.framework */, + E29BB2281E436F310020F5B6 /* CloudCoreTests-iOS.xctest */, + E29D11821E69B30C00E3DCBF /* CloudCoreTests-macOS.xctest */, + ); + name = Products; + sourceTree = ""; + }; + D5C629691C3A809D007F7B7C /* Sources */ = { + isa = PBXGroup; + children = ( + E2075FF11E4BB6EF00E31F1F /* Classes */, + E2D507961E464FEA0038B6F8 /* Enum */, + E29BB21F1E433FDA0020F5B6 /* Extensions */, + E2E296C81E49DA0100E7D6ED /* Model */, + E23C47931E48CE54004310F9 /* Protocols */, + ); + path = Sources; + sourceTree = ""; + }; + E2075FF11E4BB6EF00E31F1F /* Classes */ = { + isa = PBXGroup; + children = ( + E2075FF31E4BB70D00E31F1F /* Fetch */, + E2075FF21E4BB6F700E31F1F /* Upload */, + E200D44C1E48E13200B707D4 /* CloudCore.swift */, + E2564BFE1E5061BC002E518B /* ErrorBlockProxy.swift */, + E2BB748D1E7EA8690048C129 /* SetupOperation.swift */, + ); + path = Classes; + sourceTree = ""; + }; + E2075FF21E4BB6F700E31F1F /* Upload */ = { + isa = PBXGroup; + children = ( + E2FA74461E769D8700C3489D /* Model */, + E288C5751E4C9519002360A1 /* ObjectToRecord */, + E23C478B1E48A404004310F9 /* CloudSaveOperationQueue.swift */, + E22C40451E42956C009469A1 /* CoreDataListener.swift */, + ); + path = Upload; + sourceTree = ""; + }; + E2075FF31E4BB70D00E31F1F /* Fetch */ = { + isa = PBXGroup; + children = ( + E277DB0F1E77FC9F00DC334A /* PublicSubscriptions */, + E2C02A171E4CDEDA001B2871 /* SubOperations */, + E2E4D83D1E76D4EF00550CBE /* FetchAndSaveOperation.swift */, + ); + path = Fetch; + sourceTree = ""; + }; + E23C47871E487CEA004310F9 /* Model */ = { + isa = PBXGroup; + children = ( + E28F0B911E671E6500BF532A /* CKRecordTests.swift */, + ); + path = Model; + sourceTree = ""; + }; + E23C47931E48CE54004310F9 /* Protocols */ = { + isa = PBXGroup; + children = ( + E23C478F1E48A587004310F9 /* CloudCoreErrorDelegate.swift */, + ); + path = Protocols; + sourceTree = ""; + }; + E247EF8A1E67771C00EBD75E /* Classes */ = { + isa = PBXGroup; + children = ( + E247EF8E1E677D1400EBD75E /* Fetch */, + E29D11771E69808800E3DCBF /* Upload */, + E247EF8B1E67773F00EBD75E /* ErrorBlockProxyTests.swift */, + ); + path = Classes; + sourceTree = ""; + }; + E247EF8E1E677D1400EBD75E /* Fetch */ = { + isa = PBXGroup; + children = ( + E247EF8F1E677D1B00EBD75E /* Operations */, + ); + path = Fetch; + sourceTree = ""; + }; + E247EF8F1E677D1B00EBD75E /* Operations */ = { + isa = PBXGroup; + children = ( + E247EF951E67873900EBD75E /* DeleteFromCoreDataOperationTests.swift */, + E20A73CB1E68608100A6851A /* RecordToCoreDataOperationTests.swift */, + ); + path = Operations; + sourceTree = ""; + }; + E277DB0F1E77FC9F00DC334A /* PublicSubscriptions */ = { + isa = PBXGroup; + children = ( + E277DB0C1E77F96400DC334A /* FetchPublicSubscriptionsOperation.swift */, + E277DB061E7726FB00DC334A /* PublicDatabaseSubscriptions.swift */, + ); + path = PublicSubscriptions; + sourceTree = ""; + }; + E288C5751E4C9519002360A1 /* ObjectToRecord */ = { + isa = PBXGroup; + children = ( + E29BB2361E4377F80020F5B6 /* CoreDataAttribute.swift */, + E24F44A51E4595B900F78819 /* CoreDataRelationship.swift */, + E2C02A0D1E4C99AD001B2871 /* ObjectToRecordConverter.swift */, + E2075FFE1E4BCD7E00E31F1F /* ObjectToRecordOperation.swift */, + ); + path = ObjectToRecord; + sourceTree = ""; + }; + E28F0B9C1E67244A00BF532A /* Extensions */ = { + isa = PBXGroup; + children = ( + E28F0B9D1E67245600BF532A /* CKRecordIDTests.swift */, + E28F0BA01E6725E700BF532A /* NSEntityDescriptionTests.swift */, + E24F44A81E459E3E00F78819 /* NSManagedObjectTests.swift */, + ); + path = Extensions; + sourceTree = ""; + }; + E28FBCDF1E43D8B40081FF3B /* Sources */ = { + isa = PBXGroup; + children = ( + E247EF8A1E67771C00EBD75E /* Classes */, + E28F0B9C1E67244A00BF532A /* Extensions */, + E23C47871E487CEA004310F9 /* Model */, + E29BB2411E439E040020F5B6 /* CoreDataTestCase.swift */, + E29BB2431E43AEAF0020F5B6 /* CorrectObject.swift */, + E247EF981E678EA200EBD75E /* CustomFunctions.swift */, + ); + path = Sources; + sourceTree = ""; + }; + E29BB21F1E433FDA0020F5B6 /* Extensions */ = { + isa = PBXGroup; + children = ( + E29BB21D1E433E050020F5B6 /* CKRecordID.swift */, + E2D390071E4A49350019BBCD /* NSEntityDescription.swift */, + E29BB2221E4346FF0020F5B6 /* NSManagedObject.swift */, + E23C47911E48B210004310F9 /* NotificationName.swift */, + ); + path = Extensions; + sourceTree = ""; + }; + E29BB2291E436F310020F5B6 /* Tests */ = { + isa = PBXGroup; + children = ( + E2FA744A1E76B5A100C3489D /* Resources */, + E28FBCDF1E43D8B40081FF3B /* Sources */, + ); + path = Tests; + sourceTree = ""; + }; + E29D11771E69808800E3DCBF /* Upload */ = { + isa = PBXGroup; + children = ( + E29D11781E69810F00E3DCBF /* ObjectToRecord */, + ); + path = Upload; + sourceTree = ""; + }; + E29D11781E69810F00E3DCBF /* ObjectToRecord */ = { + isa = PBXGroup; + children = ( + E29D11791E69813F00E3DCBF /* CoreDataAttributeTests.swift */, + E29D117B1E69A44C00E3DCBF /* CoreDataRelationshipTests.swift */, + E2A3F9441E69B6EC007A65EB /* ObjectToRecordOperationTests.swift */, + ); + path = ObjectToRecord; + sourceTree = ""; + }; + E2C02A171E4CDEDA001B2871 /* SubOperations */ = { + isa = PBXGroup; + children = ( + E2075FF81E4BBEAC00E31F1F /* AsynchronousOperation.swift */, + E2C02A181E4CDEF1001B2871 /* DeleteFromCoreDataOperation.swift */, + E2C02A0F1E4CBEBB001B2871 /* FetchDatabaseChangesOperation.swift */, + E2C02A131E4CC2A5001B2871 /* FetchRecordZoneChangesOperation.swift */, + E21FA03D1E4A7E7200B1DAA2 /* RecordToCoreDataOperation.swift */, + ); + path = SubOperations; + sourceTree = ""; + }; + E2D507961E464FEA0038B6F8 /* Enum */ = { + isa = PBXGroup; + children = ( + E29BB21B1E43381D0020F5B6 /* CloudCoreError.swift */, + E2C3A6D01E4A8EAF009151F3 /* FetchResult.swift */, + ); + path = Enum; + sourceTree = ""; + }; + E2E296C81E49DA0100E7D6ED /* Model */ = { + isa = PBXGroup; + children = ( + E29BB2201E4344E80020F5B6 /* CKRecord.swift */, + E29BB2191E4334590020F5B6 /* CloudCoreConfig.swift */, + E22A53D91E4A8743009286C0 /* CloudKitAttribute.swift */, + E2EE20061E4E6DCE0060F769 /* ServiceAttributeName.swift */, + E2E296C91E49DA0800E7D6ED /* Tokens.swift */, + ); + path = Model; + sourceTree = ""; + }; + E2FA74461E769D8700C3489D /* Model */ = { + isa = PBXGroup; + children = ( + E2FA74471E769D9400C3489D /* RecordIDWithDatabase.swift */, + E2FA74431E769BF900C3489D /* RecordWithDatabase.swift */, + ); + path = Model; + sourceTree = ""; + }; + E2FA744A1E76B5A100C3489D /* Resources */ = { + isa = PBXGroup; + children = ( + E29BB22C1E436F310020F5B6 /* Info.plist */, + E29BB2331E436F720020F5B6 /* model.xcdatamodeld */, + ); + path = Resources; + sourceTree = ""; + }; + E2FA744B1E76B7AC00C3489D /* Resources */ = { + isa = PBXGroup; + children = ( + D5C6298C1C3A8BBD007F7B7C /* Info-Mac.plist */, + D5C6298B1C3A8BBD007F7B7C /* Info-iOS.plist */, + ); + path = Resources; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXHeadersBuildPhase section */ + D5B2E89C1C3A780C00C0327D /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D5C6293D1C3A7FAA007F7B7C /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXHeadersBuildPhase section */ + +/* Begin PBXNativeTarget section */ + D5B2E89E1C3A780C00C0327D /* CloudCore-iOS */ = { + isa = PBXNativeTarget; + buildConfigurationList = D5B2E8B31C3A780C00C0327D /* Build configuration list for PBXNativeTarget "CloudCore-iOS" */; + buildPhases = ( + D5B2E89A1C3A780C00C0327D /* Sources */, + D5B2E89B1C3A780C00C0327D /* Frameworks */, + D5B2E89C1C3A780C00C0327D /* Headers */, + D5B2E89D1C3A780C00C0327D /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = "CloudCore-iOS"; + productName = CloudCore; + productReference = D5B2E89F1C3A780C00C0327D /* CloudCore.framework */; + productType = "com.apple.product-type.framework"; + }; + D5C6293F1C3A7FAA007F7B7C /* CloudCore-Mac */ = { + isa = PBXNativeTarget; + buildConfigurationList = D5C629511C3A7FAA007F7B7C /* Build configuration list for PBXNativeTarget "CloudCore-Mac" */; + buildPhases = ( + D5C6293B1C3A7FAA007F7B7C /* Sources */, + D5C6293C1C3A7FAA007F7B7C /* Frameworks */, + D5C6293D1C3A7FAA007F7B7C /* Headers */, + D5C6293E1C3A7FAA007F7B7C /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = "CloudCore-Mac"; + productName = "CloudCore-Mac"; + productReference = D5C629401C3A7FAA007F7B7C /* CloudCore.framework */; + productType = "com.apple.product-type.framework"; + }; + E29BB2271E436F310020F5B6 /* CloudCoreTests-iOS */ = { + isa = PBXNativeTarget; + buildConfigurationList = E29BB2301E436F310020F5B6 /* Build configuration list for PBXNativeTarget "CloudCoreTests-iOS" */; + buildPhases = ( + E29BB2241E436F310020F5B6 /* Sources */, + E29BB2251E436F310020F5B6 /* Frameworks */, + E29BB2261E436F310020F5B6 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + E29BB22F1E436F310020F5B6 /* PBXTargetDependency */, + ); + name = "CloudCoreTests-iOS"; + productName = "CloudCoreTests-iOS"; + productReference = E29BB2281E436F310020F5B6 /* CloudCoreTests-iOS.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + E29D11811E69B30C00E3DCBF /* CloudCoreTests-macOS */ = { + isa = PBXNativeTarget; + buildConfigurationList = E29D118A1E69B30C00E3DCBF /* Build configuration list for PBXNativeTarget "CloudCoreTests-macOS" */; + buildPhases = ( + E29D117E1E69B30C00E3DCBF /* Sources */, + E29D117F1E69B30C00E3DCBF /* Frameworks */, + E29D11801E69B30C00E3DCBF /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + E29D11891E69B30C00E3DCBF /* PBXTargetDependency */, + ); + name = "CloudCoreTests-macOS"; + productName = "CloudCoreTests-macOS"; + productReference = E29D11821E69B30C00E3DCBF /* CloudCoreTests-macOS.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + D5B2E8961C3A780C00C0327D /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 0820; + LastUpgradeCheck = 0820; + ORGANIZATIONNAME = "Vasily Ulianov"; + TargetAttributes = { + D5B2E89E1C3A780C00C0327D = { + CreatedOnToolsVersion = 7.2; + LastSwiftMigration = 0800; + }; + D5C6293F1C3A7FAA007F7B7C = { + CreatedOnToolsVersion = 7.2; + }; + E29BB2271E436F310020F5B6 = { + CreatedOnToolsVersion = 8.2.1; + DevelopmentTeam = 7X2PJ6H6YM; + ProvisioningStyle = Automatic; + }; + E29D11811E69B30C00E3DCBF = { + CreatedOnToolsVersion = 8.2.1; + DevelopmentTeam = 7X2PJ6H6YM; + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = D5B2E8991C3A780C00C0327D /* Build configuration list for PBXProject "CloudCore" */; + compatibilityVersion = "Xcode 3.2"; + developmentRegion = English; + hasScannedForEncodings = 0; + knownRegions = ( + en, + ); + mainGroup = D5B2E8951C3A780C00C0327D; + productRefGroup = D5B2E8A01C3A780C00C0327D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + D5B2E89E1C3A780C00C0327D /* CloudCore-iOS */, + D5C6293F1C3A7FAA007F7B7C /* CloudCore-Mac */, + E29BB2271E436F310020F5B6 /* CloudCoreTests-iOS */, + E29D11811E69B30C00E3DCBF /* CloudCoreTests-macOS */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + D5B2E89D1C3A780C00C0327D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D5C6293E1C3A7FAA007F7B7C /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + E29BB2261E436F310020F5B6 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + E29D11801E69B30C00E3DCBF /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + D5B2E89A1C3A780C00C0327D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + E21FA03E1E4A7E7200B1DAA2 /* RecordToCoreDataOperation.swift in Sources */, + E2E4D8411E76D5A600550CBE /* FetchAndSaveOperation.swift in Sources */, + E2C02A141E4CC2A5001B2871 /* FetchRecordZoneChangesOperation.swift in Sources */, + E277DB071E7726FB00DC334A /* PublicDatabaseSubscriptions.swift in Sources */, + E2C02A101E4CBEBB001B2871 /* FetchDatabaseChangesOperation.swift in Sources */, + E2C02A191E4CDEF1001B2871 /* DeleteFromCoreDataOperation.swift in Sources */, + E29BB21A1E4334590020F5B6 /* CloudCoreConfig.swift in Sources */, + E2EE20071E4E6DCE0060F769 /* ServiceAttributeName.swift in Sources */, + E29BB21E1E433E050020F5B6 /* CKRecordID.swift in Sources */, + E23C47901E48A587004310F9 /* CloudCoreErrorDelegate.swift in Sources */, + E23C47921E48B210004310F9 /* NotificationName.swift in Sources */, + E23C478C1E48A404004310F9 /* CloudSaveOperationQueue.swift in Sources */, + E2FA74441E769BF900C3489D /* RecordWithDatabase.swift in Sources */, + E2C3A6D11E4A8EAF009151F3 /* FetchResult.swift in Sources */, + E22C40461E42956C009469A1 /* CoreDataListener.swift in Sources */, + E2075FF91E4BBEAC00E31F1F /* AsynchronousOperation.swift in Sources */, + E24F44A61E4595B900F78819 /* CoreDataRelationship.swift in Sources */, + E2BB748E1E7EA8690048C129 /* SetupOperation.swift in Sources */, + E29BB2371E4377F80020F5B6 /* CoreDataAttribute.swift in Sources */, + E2E296CA1E49DA0800E7D6ED /* Tokens.swift in Sources */, + E2075FFF1E4BCD7E00E31F1F /* ObjectToRecordOperation.swift in Sources */, + E277DB0D1E77F96400DC334A /* FetchPublicSubscriptionsOperation.swift in Sources */, + E2564BFF1E5061BC002E518B /* ErrorBlockProxy.swift in Sources */, + E2C02A0E1E4C99AD001B2871 /* ObjectToRecordConverter.swift in Sources */, + E2FA74481E769D9400C3489D /* RecordIDWithDatabase.swift in Sources */, + E29BB2211E4344E80020F5B6 /* CKRecord.swift in Sources */, + E2D390081E4A49350019BBCD /* NSEntityDescription.swift in Sources */, + E29BB2231E4346FF0020F5B6 /* NSManagedObject.swift in Sources */, + E200D44D1E48E13200B707D4 /* CloudCore.swift in Sources */, + E29BB21C1E43381D0020F5B6 /* CloudCoreError.swift in Sources */, + E22A53DA1E4A8743009286C0 /* CloudKitAttribute.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D5C6293B1C3A7FAA007F7B7C /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + E2E4D8401E76D5A500550CBE /* FetchAndSaveOperation.swift in Sources */, + E29D11911E69B31E00E3DCBF /* RecordToCoreDataOperation.swift in Sources */, + E28FBCE41E43DE6F0081FF3B /* CloudCoreConfig.swift in Sources */, + E277DB081E7726FB00DC334A /* PublicDatabaseSubscriptions.swift in Sources */, + E29D11971E69B32900E3DCBF /* ErrorBlockProxy.swift in Sources */, + E29D118E1E69B31E00E3DCBF /* DeleteFromCoreDataOperation.swift in Sources */, + E23C47981E48CE9C004310F9 /* CloudSaveOperationQueue.swift in Sources */, + E29D11991E69B33100E3DCBF /* NSEntityDescription.swift in Sources */, + E29D11931E69B32400E3DCBF /* ObjectToRecordConverter.swift in Sources */, + E23C47971E48CE9C004310F9 /* NotificationName.swift in Sources */, + E29D11981E69B32E00E3DCBF /* FetchResult.swift in Sources */, + E2FA74451E769BF900C3489D /* RecordWithDatabase.swift in Sources */, + E28FBCEC1E43DE760081FF3B /* CoreDataAttribute.swift in Sources */, + E29D119A1E69B33400E3DCBF /* CloudKitAttribute.swift in Sources */, + E28FBCEA1E43DE760081FF3B /* CoreDataListener.swift in Sources */, + E23C47951E48CE9C004310F9 /* CoreDataRelationship.swift in Sources */, + E2BB748F1E7EB2B00048C129 /* SetupOperation.swift in Sources */, + E29D11951E69B32900E3DCBF /* CloudCore.swift in Sources */, + E28FBCE31E43DE6F0081FF3B /* CloudCoreError.swift in Sources */, + E29D119C1E69B33400E3DCBF /* Tokens.swift in Sources */, + E277DB0E1E77F96400DC334A /* FetchPublicSubscriptionsOperation.swift in Sources */, + E28FBCE21E43DE6F0081FF3B /* NSManagedObject.swift in Sources */, + E29D11941E69B32400E3DCBF /* ObjectToRecordOperation.swift in Sources */, + E2FA74491E769D9400C3489D /* RecordIDWithDatabase.swift in Sources */, + E23C47941E48CE9C004310F9 /* CloudCoreErrorDelegate.swift in Sources */, + E28FBCE01E43DE6F0081FF3B /* CKRecordID.swift in Sources */, + E29D118F1E69B31E00E3DCBF /* FetchDatabaseChangesOperation.swift in Sources */, + E28FBCE11E43DE6F0081FF3B /* CKRecord.swift in Sources */, + E29D118D1E69B31E00E3DCBF /* AsynchronousOperation.swift in Sources */, + E29D11901E69B31E00E3DCBF /* FetchRecordZoneChangesOperation.swift in Sources */, + E29D119B1E69B33400E3DCBF /* ServiceAttributeName.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + E29BB2241E436F310020F5B6 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + E28F0BA31E67280100BF532A /* NSManagedObjectTests.swift in Sources */, + E28F0BA21E67260900BF532A /* NSEntityDescriptionTests.swift in Sources */, + E28F0B8F1E671DFA00BF532A /* CorrectObject.swift in Sources */, + E2A3F9451E69B6EC007A65EB /* ObjectToRecordOperationTests.swift in Sources */, + E20A73CC1E68608100A6851A /* RecordToCoreDataOperationTests.swift in Sources */, + E28F0B8E1E671DFA00BF532A /* CoreDataTestCase.swift in Sources */, + E29D117A1E69813F00E3DCBF /* CoreDataAttributeTests.swift in Sources */, + E28F0B931E671E7400BF532A /* CKRecordTests.swift in Sources */, + E29BB2351E436F720020F5B6 /* model.xcdatamodeld in Sources */, + E28F0B9F1E67245A00BF532A /* CKRecordIDTests.swift in Sources */, + E247EF971E67873E00EBD75E /* DeleteFromCoreDataOperationTests.swift in Sources */, + E29D117D1E69A47700E3DCBF /* CoreDataRelationshipTests.swift in Sources */, + E247EF9A1E678EAC00EBD75E /* CustomFunctions.swift in Sources */, + E247EF8D1E67775500EBD75E /* ErrorBlockProxyTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + E29D117E1E69B30C00E3DCBF /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + E29D119F1E69B36700E3DCBF /* RecordToCoreDataOperationTests.swift in Sources */, + E29D11A01E69B36700E3DCBF /* CoreDataAttributeTests.swift in Sources */, + E29D11A51E69B36700E3DCBF /* CKRecordTests.swift in Sources */, + E2A3F9461E69B6EC007A65EB /* ObjectToRecordOperationTests.swift in Sources */, + E29D11A71E69B36700E3DCBF /* CorrectObject.swift in Sources */, + E29D119E1E69B36700E3DCBF /* DeleteFromCoreDataOperationTests.swift in Sources */, + E29D11A11E69B36700E3DCBF /* CoreDataRelationshipTests.swift in Sources */, + E29D11A81E69B36700E3DCBF /* CustomFunctions.swift in Sources */, + E29D11A21E69B36700E3DCBF /* CKRecordIDTests.swift in Sources */, + E29D11A91E69B3B200E3DCBF /* model.xcdatamodeld in Sources */, + E29D11A61E69B36700E3DCBF /* CoreDataTestCase.swift in Sources */, + E29D11A31E69B36700E3DCBF /* NSEntityDescriptionTests.swift in Sources */, + E29D119D1E69B36700E3DCBF /* ErrorBlockProxyTests.swift in Sources */, + E29D11A41E69B36700E3DCBF /* NSManagedObjectTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + E29BB22F1E436F310020F5B6 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D5B2E89E1C3A780C00C0327D /* CloudCore-iOS */; + targetProxy = E29BB22E1E436F310020F5B6 /* PBXContainerItemProxy */; + }; + E29D11891E69B30C00E3DCBF /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D5C6293F1C3A7FAA007F7B7C /* CloudCore-Mac */; + targetProxy = E29D11881E69B30C00E3DCBF /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + D5B2E8B11C3A780C00C0327D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 10.0; + MACOSX_DEPLOYMENT_TARGET = 10.12; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 3.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Debug; + }; + D5B2E8B21C3A780C00C0327D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 10.0; + MACOSX_DEPLOYMENT_TARGET = 10.12; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; + SWIFT_VERSION = 3.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Release; + }; + D5B2E8B41C3A780C00C0327D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = ""; + DEFINES_MODULE = YES; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + INFOPLIST_FILE = "$(SRCROOT)/Resources/Info-iOS.plist"; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = "uvasily.CloudCore-iOS"; + PRODUCT_NAME = CloudCore; + SKIP_INSTALL = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + D5B2E8B51C3A780C00C0327D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = ""; + DEFINES_MODULE = YES; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + INFOPLIST_FILE = "$(SRCROOT)/Resources/Info-iOS.plist"; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = "uvasily.CloudCore-iOS"; + PRODUCT_NAME = CloudCore; + SKIP_INSTALL = YES; + }; + name = Release; + }; + D5C629521C3A7FAA007F7B7C /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = "-"; + COMBINE_HIDPI_IMAGES = YES; + DEFINES_MODULE = YES; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + FRAMEWORK_VERSION = A; + INFOPLIST_FILE = "$(SRCROOT)/Resources/Info-Mac.plist"; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks @loader_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = "uvasily.CloudCore-Mac"; + PRODUCT_NAME = CloudCore; + SDKROOT = macosx; + SKIP_INSTALL = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + D5C629531C3A7FAA007F7B7C /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = "-"; + COMBINE_HIDPI_IMAGES = YES; + DEFINES_MODULE = YES; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + FRAMEWORK_VERSION = A; + INFOPLIST_FILE = "$(SRCROOT)/Resources/Info-Mac.plist"; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks @loader_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = "uvasily.CloudCore-Mac"; + PRODUCT_NAME = CloudCore; + SDKROOT = macosx; + SKIP_INSTALL = YES; + }; + name = Release; + }; + E29BB2311E436F310020F5B6 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + DEVELOPMENT_TEAM = 7X2PJ6H6YM; + INFOPLIST_FILE = Tests/Resources/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 10.2; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = "uvasily.CloudCoreTests-iOS"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_VERSION = 3.0; + }; + name = Debug; + }; + E29BB2321E436F310020F5B6 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + DEVELOPMENT_TEAM = 7X2PJ6H6YM; + INFOPLIST_FILE = Tests/Resources/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 10.2; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = "uvasily.CloudCoreTests-iOS"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 3.0; + }; + name = Release; + }; + E29D118B1E69B30C00E3DCBF /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CODE_SIGN_IDENTITY = "Mac Developer"; + COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = 7X2PJ6H6YM; + INFOPLIST_FILE = Tests/Resources/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks @loader_path/../Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = "uvasily.CloudCoreTests-macOS"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_VERSION = 3.0; + }; + name = Debug; + }; + E29D118C1E69B30C00E3DCBF /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CODE_SIGN_IDENTITY = "Mac Developer"; + COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = 7X2PJ6H6YM; + INFOPLIST_FILE = Tests/Resources/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks @loader_path/../Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = "uvasily.CloudCoreTests-macOS"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_VERSION = 3.0; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + D5B2E8991C3A780C00C0327D /* Build configuration list for PBXProject "CloudCore" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D5B2E8B11C3A780C00C0327D /* Debug */, + D5B2E8B21C3A780C00C0327D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + D5B2E8B31C3A780C00C0327D /* Build configuration list for PBXNativeTarget "CloudCore-iOS" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D5B2E8B41C3A780C00C0327D /* Debug */, + D5B2E8B51C3A780C00C0327D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + D5C629511C3A7FAA007F7B7C /* Build configuration list for PBXNativeTarget "CloudCore-Mac" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D5C629521C3A7FAA007F7B7C /* Debug */, + D5C629531C3A7FAA007F7B7C /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + E29BB2301E436F310020F5B6 /* Build configuration list for PBXNativeTarget "CloudCoreTests-iOS" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E29BB2311E436F310020F5B6 /* Debug */, + E29BB2321E436F310020F5B6 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + E29D118A1E69B30C00E3DCBF /* Build configuration list for PBXNativeTarget "CloudCoreTests-macOS" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E29D118B1E69B30C00E3DCBF /* Debug */, + E29D118C1E69B30C00E3DCBF /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCVersionGroup section */ + E29BB2331E436F720020F5B6 /* model.xcdatamodeld */ = { + isa = XCVersionGroup; + children = ( + E29BB2341E436F720020F5B6 /* model.xcdatamodel */, + ); + currentVersion = E29BB2341E436F720020F5B6 /* model.xcdatamodel */; + path = model.xcdatamodeld; + sourceTree = ""; + versionGroupType = wrapper.xcdatamodel; + }; +/* End XCVersionGroup section */ + }; + rootObject = D5B2E8961C3A780C00C0327D /* Project object */; +} diff --git a/CloudCore.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/CloudCore.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100755 index 00000000..95f8f3dd --- /dev/null +++ b/CloudCore.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/CloudCore.xcodeproj/xcshareddata/xcbaselines/E29BB2271E436F310020F5B6.xcbaseline/B42B2E0B-5811-46E5-BF5E-3CC5E12577DD.plist b/CloudCore.xcodeproj/xcshareddata/xcbaselines/E29BB2271E436F310020F5B6.xcbaseline/B42B2E0B-5811-46E5-BF5E-3CC5E12577DD.plist new file mode 100644 index 00000000..3daf3a6a --- /dev/null +++ b/CloudCore.xcodeproj/xcshareddata/xcbaselines/E29BB2271E436F310020F5B6.xcbaseline/B42B2E0B-5811-46E5-BF5E-3CC5E12577DD.plist @@ -0,0 +1,58 @@ + + + + + classNames + + DeleteFromCoreDataOperationTests + + testOperationPerfomance() + + com.apple.XCTPerformanceMetric_WallClockTime + + baselineAverage + 0.197 + baselineIntegrationDisplayName + Local Baseline + + + + ObjectToRecordOperationTests + + testOperationPerfomance() + + com.apple.XCTPerformanceMetric_WallClockTime + + baselineAverage + 0.18121 + baselineIntegrationDisplayName + Local Baseline + + + + RecordToCoreDataOperationTests + + testDateFormatterPerformance() + + com.apple.XCTPerformanceMetric_WallClockTime + + baselineAverage + 0.117 + baselineIntegrationDisplayName + Local Baseline + + + testOperationsPerformance() + + com.apple.XCTPerformanceMetric_WallClockTime + + baselineAverage + 0.083 + baselineIntegrationDisplayName + Local Baseline + + + + + + diff --git a/CloudCore.xcodeproj/xcshareddata/xcbaselines/E29BB2271E436F310020F5B6.xcbaseline/Info.plist b/CloudCore.xcodeproj/xcshareddata/xcbaselines/E29BB2271E436F310020F5B6.xcbaseline/Info.plist new file mode 100644 index 00000000..d499cae1 --- /dev/null +++ b/CloudCore.xcodeproj/xcshareddata/xcbaselines/E29BB2271E436F310020F5B6.xcbaseline/Info.plist @@ -0,0 +1,40 @@ + + + + + runDestinationsByUUID + + B42B2E0B-5811-46E5-BF5E-3CC5E12577DD + + localComputer + + busSpeedInMHz + 100 + cpuCount + 1 + cpuKind + Intel Core i7 + cpuSpeedInMHz + 2500 + logicalCPUCoresPerPackage + 8 + modelCode + MacBookPro11,5 + physicalCPUCoresPerPackage + 4 + platformIdentifier + com.apple.platform.macosx + + targetArchitecture + x86_64 + targetDevice + + modelCode + iPhone9,2 + platformIdentifier + com.apple.platform.iphonesimulator + + + + + diff --git a/CloudCore.xcodeproj/xcshareddata/xcschemes/CloudCore-Mac.xcscheme b/CloudCore.xcodeproj/xcshareddata/xcschemes/CloudCore-Mac.xcscheme new file mode 100755 index 00000000..e01f07be --- /dev/null +++ b/CloudCore.xcodeproj/xcshareddata/xcschemes/CloudCore-Mac.xcscheme @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/CloudCore.xcodeproj/xcshareddata/xcschemes/CloudCore-iOS.xcscheme b/CloudCore.xcodeproj/xcshareddata/xcschemes/CloudCore-iOS.xcscheme new file mode 100755 index 00000000..ad020713 --- /dev/null +++ b/CloudCore.xcodeproj/xcshareddata/xcschemes/CloudCore-iOS.xcscheme @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/.gitignore b/Example/.gitignore new file mode 100644 index 00000000..8269b5cb --- /dev/null +++ b/Example/.gitignore @@ -0,0 +1,2 @@ +Pods/ +Podfile.lock diff --git a/Example/CloudCoreExample.xcodeproj/project.pbxproj b/Example/CloudCoreExample.xcodeproj/project.pbxproj new file mode 100644 index 00000000..a48266de --- /dev/null +++ b/Example/CloudCoreExample.xcodeproj/project.pbxproj @@ -0,0 +1,489 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 46; + objects = { + +/* Begin PBXBuildFile section */ + E23BE70C1EA4FD78008F4F23 /* ReferenceProxy in Frameworks */ = {isa = PBXBuildFile; fileRef = E23BE6FB1EA4CC1C008F4F23 /* CloudCore.framework */; }; + E23BE70D1EA4FD78008F4F23 /* ReferenceProxy in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = E23BE6FB1EA4CC1C008F4F23 /* CloudCore.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + E26BDD991E75A0AC00994CE7 /* MasterViewController+FRC.swift in Sources */ = {isa = PBXBuildFile; fileRef = E26BDD981E75A0AC00994CE7 /* MasterViewController+FRC.swift */; }; + E2C3E3541E53299800A733BF /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C3E3531E53299800A733BF /* AppDelegate.swift */; }; + E2C3E3571E53299800A733BF /* Model.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = E2C3E3551E53299800A733BF /* Model.xcdatamodeld */; }; + E2C3E3591E53299800A733BF /* MasterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C3E3581E53299800A733BF /* MasterViewController.swift */; }; + E2C3E35B1E53299800A733BF /* DetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C3E35A1E53299800A733BF /* DetailViewController.swift */; }; + E2C3E35E1E53299800A733BF /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = E2C3E35C1E53299800A733BF /* Main.storyboard */; }; + E2C3E3601E53299800A733BF /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E2C3E35F1E53299800A733BF /* Assets.xcassets */; }; + E2C3E3631E53299800A733BF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = E2C3E3611E53299800A733BF /* LaunchScreen.storyboard */; }; + E2C3E3701E532AA600A733BF /* CloudKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E2C3E36F1E532AA600A733BF /* CloudKit.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + E23BE6FA1EA4CC1C008F4F23 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = E23BE6F31EA4CC1C008F4F23 /* CloudCore.xcodeproj */; + proxyType = 2; + remoteGlobalIDString = D5B2E89F1C3A780C00C0327D; + remoteInfo = "CloudCore-iOS"; + }; + E23BE6FC1EA4CC1C008F4F23 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = E23BE6F31EA4CC1C008F4F23 /* CloudCore.xcodeproj */; + proxyType = 2; + remoteGlobalIDString = D5C629401C3A7FAA007F7B7C; + remoteInfo = "CloudCore-Mac"; + }; + E23BE6FE1EA4CC1C008F4F23 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = E23BE6F31EA4CC1C008F4F23 /* CloudCore.xcodeproj */; + proxyType = 2; + remoteGlobalIDString = E29BB2281E436F310020F5B6; + remoteInfo = "CloudCoreTests-iOS"; + }; + E23BE7001EA4CC1C008F4F23 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = E23BE6F31EA4CC1C008F4F23 /* CloudCore.xcodeproj */; + proxyType = 2; + remoteGlobalIDString = E29D11821E69B30C00E3DCBF; + remoteInfo = "CloudCoreTests-macOS"; + }; + E23BE70E1EA4FD78008F4F23 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = E23BE6F31EA4CC1C008F4F23 /* CloudCore.xcodeproj */; + proxyType = 1; + remoteGlobalIDString = D5B2E89E1C3A780C00C0327D; + remoteInfo = "CloudCore-iOS"; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + E23BE7101EA4FD78008F4F23 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + E23BE70D1EA4FD78008F4F23 /* ReferenceProxy in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + E23BE6F31EA4CC1C008F4F23 /* CloudCore.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = CloudCore.xcodeproj; path = ../CloudCore.xcodeproj; sourceTree = ""; }; + E26BDD981E75A0AC00994CE7 /* MasterViewController+FRC.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "MasterViewController+FRC.swift"; sourceTree = ""; }; + E2C3E3501E53299800A733BF /* CloudCoreExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CloudCoreExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; + E2C3E3531E53299800A733BF /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + E2C3E3561E53299800A733BF /* Model.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Model.xcdatamodel; sourceTree = ""; }; + E2C3E3581E53299800A733BF /* MasterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MasterViewController.swift; sourceTree = ""; }; + E2C3E35A1E53299800A733BF /* DetailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailViewController.swift; sourceTree = ""; }; + E2C3E35D1E53299800A733BF /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + E2C3E35F1E53299800A733BF /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + E2C3E3621E53299800A733BF /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + E2C3E3641E53299800A733BF /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + E2C3E36A1E532A6E00A733BF /* CloudCoreExample.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = CloudCoreExample.entitlements; sourceTree = ""; }; + E2C3E36F1E532AA600A733BF /* CloudKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CloudKit.framework; path = System/Library/Frameworks/CloudKit.framework; sourceTree = SDKROOT; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + E2C3E34D1E53299800A733BF /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + E2C3E3701E532AA600A733BF /* CloudKit.framework in Frameworks */, + E23BE70C1EA4FD78008F4F23 /* ReferenceProxy in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + E23BE6F41EA4CC1C008F4F23 /* Products */ = { + isa = PBXGroup; + children = ( + E23BE6FB1EA4CC1C008F4F23 /* CloudCore.framework */, + E23BE6FD1EA4CC1C008F4F23 /* CloudCore.framework */, + E23BE6FF1EA4CC1C008F4F23 /* CloudCoreTests-iOS.xctest */, + E23BE7011EA4CC1C008F4F23 /* CloudCoreTests-macOS.xctest */, + ); + name = Products; + sourceTree = ""; + }; + E26BDD971E759D5E00994CE7 /* Resources */ = { + isa = PBXGroup; + children = ( + E2C3E35F1E53299800A733BF /* Assets.xcassets */, + E2C3E36A1E532A6E00A733BF /* CloudCoreExample.entitlements */, + E2C3E3641E53299800A733BF /* Info.plist */, + E2C3E3611E53299800A733BF /* LaunchScreen.storyboard */, + E2C3E35C1E53299800A733BF /* Main.storyboard */, + E2C3E3551E53299800A733BF /* Model.xcdatamodeld */, + ); + path = Resources; + sourceTree = ""; + }; + E2C3E3471E53299800A733BF = { + isa = PBXGroup; + children = ( + EC26894FD30A1DA2AD22664A /* Frameworks */, + E2C3E3511E53299800A733BF /* Products */, + E26BDD971E759D5E00994CE7 /* Resources */, + E2C3E3521E53299800A733BF /* Sources */, + E23BE6F31EA4CC1C008F4F23 /* CloudCore.xcodeproj */, + ); + sourceTree = ""; + }; + E2C3E3511E53299800A733BF /* Products */ = { + isa = PBXGroup; + children = ( + E2C3E3501E53299800A733BF /* CloudCoreExample.app */, + ); + name = Products; + sourceTree = ""; + }; + E2C3E3521E53299800A733BF /* Sources */ = { + isa = PBXGroup; + children = ( + E2C3E3531E53299800A733BF /* AppDelegate.swift */, + E2C3E35A1E53299800A733BF /* DetailViewController.swift */, + E26BDD981E75A0AC00994CE7 /* MasterViewController+FRC.swift */, + E2C3E3581E53299800A733BF /* MasterViewController.swift */, + ); + path = Sources; + sourceTree = ""; + }; + EC26894FD30A1DA2AD22664A /* Frameworks */ = { + isa = PBXGroup; + children = ( + E2C3E36F1E532AA600A733BF /* CloudKit.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + E2C3E34F1E53299800A733BF /* CloudCoreExample */ = { + isa = PBXNativeTarget; + buildConfigurationList = E2C3E3671E53299800A733BF /* Build configuration list for PBXNativeTarget "CloudCoreExample" */; + buildPhases = ( + E2C3E34C1E53299800A733BF /* Sources */, + E2C3E34D1E53299800A733BF /* Frameworks */, + E2C3E34E1E53299800A733BF /* Resources */, + E23BE7101EA4FD78008F4F23 /* Embed Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + E23BE70F1EA4FD78008F4F23 /* PBXTargetDependency */, + ); + name = CloudCoreExample; + productName = CloudTest2; + productReference = E2C3E3501E53299800A733BF /* CloudCoreExample.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + E2C3E3481E53299800A733BF /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 0820; + LastUpgradeCheck = 0820; + ORGANIZATIONNAME = "Vasily Ulianov"; + TargetAttributes = { + E2C3E34F1E53299800A733BF = { + CreatedOnToolsVersion = 8.2.1; + DevelopmentTeam = 7X2PJ6H6YM; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.BackgroundModes = { + enabled = 1; + }; + com.apple.Push = { + enabled = 1; + }; + com.apple.iCloud = { + enabled = 1; + }; + }; + }; + }; + }; + buildConfigurationList = E2C3E34B1E53299800A733BF /* Build configuration list for PBXProject "CloudCoreExample" */; + compatibilityVersion = "Xcode 3.2"; + developmentRegion = English; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = E2C3E3471E53299800A733BF; + productRefGroup = E2C3E3511E53299800A733BF /* Products */; + projectDirPath = ""; + projectReferences = ( + { + ProductGroup = E23BE6F41EA4CC1C008F4F23 /* Products */; + ProjectRef = E23BE6F31EA4CC1C008F4F23 /* CloudCore.xcodeproj */; + }, + ); + projectRoot = ""; + targets = ( + E2C3E34F1E53299800A733BF /* CloudCoreExample */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXReferenceProxy section */ + E23BE6FB1EA4CC1C008F4F23 /* CloudCore.framework */ = { + isa = PBXReferenceProxy; + fileType = wrapper.framework; + path = CloudCore.framework; + remoteRef = E23BE6FA1EA4CC1C008F4F23 /* PBXContainerItemProxy */; + sourceTree = BUILT_PRODUCTS_DIR; + }; + E23BE6FD1EA4CC1C008F4F23 /* CloudCore.framework */ = { + isa = PBXReferenceProxy; + fileType = wrapper.framework; + path = CloudCore.framework; + remoteRef = E23BE6FC1EA4CC1C008F4F23 /* PBXContainerItemProxy */; + sourceTree = BUILT_PRODUCTS_DIR; + }; + E23BE6FF1EA4CC1C008F4F23 /* CloudCoreTests-iOS.xctest */ = { + isa = PBXReferenceProxy; + fileType = wrapper.cfbundle; + path = "CloudCoreTests-iOS.xctest"; + remoteRef = E23BE6FE1EA4CC1C008F4F23 /* PBXContainerItemProxy */; + sourceTree = BUILT_PRODUCTS_DIR; + }; + E23BE7011EA4CC1C008F4F23 /* CloudCoreTests-macOS.xctest */ = { + isa = PBXReferenceProxy; + fileType = wrapper.cfbundle; + path = "CloudCoreTests-macOS.xctest"; + remoteRef = E23BE7001EA4CC1C008F4F23 /* PBXContainerItemProxy */; + sourceTree = BUILT_PRODUCTS_DIR; + }; +/* End PBXReferenceProxy section */ + +/* Begin PBXResourcesBuildPhase section */ + E2C3E34E1E53299800A733BF /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + E2C3E3631E53299800A733BF /* LaunchScreen.storyboard in Resources */, + E2C3E3601E53299800A733BF /* Assets.xcassets in Resources */, + E2C3E35E1E53299800A733BF /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + E2C3E34C1E53299800A733BF /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + E2C3E3571E53299800A733BF /* Model.xcdatamodeld in Sources */, + E2C3E3541E53299800A733BF /* AppDelegate.swift in Sources */, + E26BDD991E75A0AC00994CE7 /* MasterViewController+FRC.swift in Sources */, + E2C3E3591E53299800A733BF /* MasterViewController.swift in Sources */, + E2C3E35B1E53299800A733BF /* DetailViewController.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + E23BE70F1EA4FD78008F4F23 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = "CloudCore-iOS"; + targetProxy = E23BE70E1EA4FD78008F4F23 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + E2C3E35C1E53299800A733BF /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + E2C3E35D1E53299800A733BF /* Base */, + ); + name = Main.storyboard; + path = .; + sourceTree = ""; + }; + E2C3E3611E53299800A733BF /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + E2C3E3621E53299800A733BF /* Base */, + ); + name = LaunchScreen.storyboard; + path = .; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + E2C3E3651E53299800A733BF /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 10.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + E2C3E3661E53299800A733BF /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 10.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + E2C3E3681E53299800A733BF /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = Resources/CloudCoreExample.entitlements; + DEVELOPMENT_TEAM = 7X2PJ6H6YM; + INFOPLIST_FILE = Resources/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 10.0; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = uvasily.CloudCoreExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 3.0; + }; + name = Debug; + }; + E2C3E3691E53299800A733BF /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = Resources/CloudCoreExample.entitlements; + DEVELOPMENT_TEAM = 7X2PJ6H6YM; + INFOPLIST_FILE = Resources/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 10.0; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = uvasily.CloudCoreExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 3.0; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + E2C3E34B1E53299800A733BF /* Build configuration list for PBXProject "CloudCoreExample" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E2C3E3651E53299800A733BF /* Debug */, + E2C3E3661E53299800A733BF /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + E2C3E3671E53299800A733BF /* Build configuration list for PBXNativeTarget "CloudCoreExample" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E2C3E3681E53299800A733BF /* Debug */, + E2C3E3691E53299800A733BF /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCVersionGroup section */ + E2C3E3551E53299800A733BF /* Model.xcdatamodeld */ = { + isa = XCVersionGroup; + children = ( + E2C3E3561E53299800A733BF /* Model.xcdatamodel */, + ); + currentVersion = E2C3E3561E53299800A733BF /* Model.xcdatamodel */; + path = Model.xcdatamodeld; + sourceTree = ""; + versionGroupType = wrapper.xcdatamodel; + }; +/* End XCVersionGroup section */ + }; + rootObject = E2C3E3481E53299800A733BF /* Project object */; +} diff --git a/Example/CloudCoreExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Example/CloudCoreExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..f15fe8fb --- /dev/null +++ b/Example/CloudCoreExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Example/CloudCoreExample.xcodeproj/xcshareddata/xcschemes/CloudCoreExample.xcscheme b/Example/CloudCoreExample.xcodeproj/xcshareddata/xcschemes/CloudCoreExample.xcscheme new file mode 100644 index 00000000..d03b13cf --- /dev/null +++ b/Example/CloudCoreExample.xcodeproj/xcshareddata/xcschemes/CloudCoreExample.xcscheme @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/Example/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..b8236c65 --- /dev/null +++ b/Example/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,48 @@ +{ + "images" : [ + { + "idiom" : "iphone", + "size" : "20x20", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "20x20", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "29x29", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "29x29", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "40x40", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "40x40", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "60x60", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "60x60", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Example/Resources/Assets.xcassets/Contents.json b/Example/Resources/Assets.xcassets/Contents.json new file mode 100644 index 00000000..da4a164c --- /dev/null +++ b/Example/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Example/Resources/Assets.xcassets/TestImage.imageset/Contents.json b/Example/Resources/Assets.xcassets/TestImage.imageset/Contents.json new file mode 100644 index 00000000..f45b62f9 --- /dev/null +++ b/Example/Resources/Assets.xcassets/TestImage.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "TestImage.jpg", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Example/Resources/Assets.xcassets/TestImage.imageset/TestImage.jpg b/Example/Resources/Assets.xcassets/TestImage.imageset/TestImage.jpg new file mode 100644 index 00000000..fcf05178 Binary files /dev/null and b/Example/Resources/Assets.xcassets/TestImage.imageset/TestImage.jpg differ diff --git a/Example/Resources/Base.lproj/LaunchScreen.storyboard b/Example/Resources/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 00000000..fdf3f97d --- /dev/null +++ b/Example/Resources/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/Resources/Base.lproj/Main.storyboard b/Example/Resources/Base.lproj/Main.storyboard new file mode 100644 index 00000000..aae6b60f --- /dev/null +++ b/Example/Resources/Base.lproj/Main.storyboard @@ -0,0 +1,148 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/Resources/CloudCoreExample.entitlements b/Example/Resources/CloudCoreExample.entitlements new file mode 100644 index 00000000..c97793b7 --- /dev/null +++ b/Example/Resources/CloudCoreExample.entitlements @@ -0,0 +1,18 @@ + + + + + aps-environment + development + com.apple.developer.icloud-container-identifiers + + iCloud.$(CFBundleIdentifier) + + com.apple.developer.icloud-services + + CloudKit + + com.apple.developer.ubiquity-kvstore-identifier + $(TeamIdentifierPrefix)$(CFBundleIdentifier) + + diff --git a/Example/Resources/Info.plist b/Example/Resources/Info.plist new file mode 100644 index 00000000..f9bff16c --- /dev/null +++ b/Example/Resources/Info.plist @@ -0,0 +1,52 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UIBackgroundModes + + remote-notification + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UIRequiredDeviceCapabilities + + armv7 + + UIStatusBarTintParameters + + UINavigationBar + + Style + UIBarStyleDefault + Translucent + + + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/Example/Resources/Model.xcdatamodeld/Model.xcdatamodel/contents b/Example/Resources/Model.xcdatamodeld/Model.xcdatamodel/contents new file mode 100644 index 00000000..348c9d19 --- /dev/null +++ b/Example/Resources/Model.xcdatamodeld/Model.xcdatamodel/contents @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Example/Sources/AppDelegate.swift b/Example/Sources/AppDelegate.swift new file mode 100644 index 00000000..38e3ce3e --- /dev/null +++ b/Example/Sources/AppDelegate.swift @@ -0,0 +1,129 @@ +// +// AppDelegate.swift +// CloudTest2 +// +// Created by Vasily Ulianov on 14.02.17. +// Copyright © 2017 Vasily Ulianov. All rights reserved. +// + +import UIKit +import CoreData +import CloudCore + +let persistentContainer = (UIApplication.shared.delegate as! AppDelegate).persistentContainer + +@UIApplicationMain +class AppDelegate: UIResponder, UIApplicationDelegate, UISplitViewControllerDelegate, CloudCoreErrorDelegate { + + // MARK: - CloudCore + + func cloudCore(saveToCloudDidFailed error: Error) { + print("\(error)") + } + + func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey : Any]? = nil) -> Bool { + // Register for push notifications about changes + UIApplication.shared.registerForRemoteNotifications() + + // Enable uploading changed local data to CoreData + CloudCore.observeCoreDataChanges(persistentContainer: self.persistentContainer, errorDelegate: self) + + return true + } + + // Notification from CloudKit about changes in remote database + func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { + // Check if it CloudKit's and CloudCore notification + if CloudCore.isCloudCoreNotification(withUserInfo: userInfo) { + // Fetch changed data from iCloud + CloudCore.fetchAndSave(using: userInfo, container: self.persistentContainer, error: nil, completion: { (fetchResult) in + completionHandler(fetchResult.uiBackgroundFetchResult) + }) + } + } + + func applicationWillTerminate(_ application: UIApplication) { + // Save tokens on exit used to differential sync + CloudCore.tokens.saveToUserDefaults() + } + + // MARK: - Default Apple initialization, you can skip that + + var window: UIWindow? + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { + // Override point for customization after application launch. + let splitViewController = self.window!.rootViewController as! UISplitViewController + let navigationController = splitViewController.viewControllers[splitViewController.viewControllers.count-1] as! UINavigationController + navigationController.topViewController!.navigationItem.leftBarButtonItem = splitViewController.displayModeButtonItem + splitViewController.delegate = self + + self.persistentContainer.viewContext.automaticallyMergesChangesFromParent = true + + let masterNavigationController = splitViewController.viewControllers[0] as! UINavigationController + let controller = masterNavigationController.topViewController as! MasterViewController + controller.managedObjectContext = self.persistentContainer.viewContext + + return true + } + + + // MARK: Split view + + func splitViewController(_ splitViewController: UISplitViewController, collapseSecondary secondaryViewController:UIViewController, onto primaryViewController:UIViewController) -> Bool { + guard let secondaryAsNavController = secondaryViewController as? UINavigationController else { return false } + guard let topAsDetailController = secondaryAsNavController.topViewController as? DetailViewController else { return false } + if topAsDetailController.detailItem == nil { + // Return true to indicate that we have handled the collapse by doing nothing; the secondary controller will be discarded. + return true + } + return false + } + + // MARK: Core Data stack + + lazy var persistentContainer: NSPersistentContainer = { + /* + The persistent container for the application. This implementation + creates and returns a container, having loaded the store for the + application to it. This property is optional since there are legitimate + error conditions that could cause the creation of the store to fail. + */ + let container = NSPersistentContainer(name: "Model") + container.loadPersistentStores(completionHandler: { (storeDescription, error) in + if let error = error as NSError? { + // Replace this implementation with code to handle the error appropriately. + // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. + + /* + Typical reasons for an error here include: + * The parent directory does not exist, cannot be created, or disallows writing. + * The persistent store is not accessible, due to permissions or data protection when the device is locked. + * The device is out of space. + * The store could not be migrated to the current model version. + Check the error message to determine what the actual problem was. + */ + fatalError("Unresolved error \(error), \(error.userInfo)") + } + }) + return container + }() + + // MARK: Core Data Saving support + + func saveContext () { + let context = persistentContainer.viewContext + if context.hasChanges { + do { + try context.save() + } catch { + // Replace this implementation with code to handle the error appropriately. + // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. + let nserror = error as NSError + fatalError("Unresolved error \(nserror), \(nserror.userInfo)") + } + } + } + +} + diff --git a/Example/Sources/DetailViewController.swift b/Example/Sources/DetailViewController.swift new file mode 100644 index 00000000..03d106d2 --- /dev/null +++ b/Example/Sources/DetailViewController.swift @@ -0,0 +1,45 @@ +// +// DetailViewController.swift +// CloudTest2 +// +// Created by Vasily Ulianov on 14.02.17. +// Copyright © 2017 Vasily Ulianov. All rights reserved. +// + +import UIKit +import CloudCore + +class DetailViewController: UIViewController { + + @IBOutlet weak var detailDescriptionLabel: UILabel! + + func configureView() { + // Update the user interface for the detail item. + if let detail = self.detailItem { + if let label = self.detailDescriptionLabel { + label.text = detail.timestamp!.description + } + } + } + + override func viewDidLoad() { + super.viewDidLoad() + // Do any additional setup after loading the view, typically from a nib. + self.configureView() + } + + override func didReceiveMemoryWarning() { + super.didReceiveMemoryWarning() + // Dispose of any resources that can be recreated. + } + + var detailItem: Event? { + didSet { + // Update the view. + self.configureView() + } + } + + +} + diff --git a/Example/Sources/MasterViewController+FRC.swift b/Example/Sources/MasterViewController+FRC.swift new file mode 100644 index 00000000..8165321a --- /dev/null +++ b/Example/Sources/MasterViewController+FRC.swift @@ -0,0 +1,79 @@ +// +// MasterViewController+FRC.swift +// CloudCoreExample +// +// Created by Vasily Ulianov on 12/03/2017. +// Copyright © 2017 Vasily Ulianov. All rights reserved. +// + +import UIKit +import CoreData + +extension MasterViewController: NSFetchedResultsControllerDelegate { + + var fetchedResultsController: NSFetchedResultsController { + if _fetchedResultsController != nil { + return _fetchedResultsController! + } + + let fetchRequest: NSFetchRequest = Event.fetchRequest() + + // Set the batch size to a suitable number. + fetchRequest.fetchBatchSize = 20 + + // Edit the sort key as appropriate. + let sortDescriptor = NSSortDescriptor(key: "timestamp", ascending: false) + + fetchRequest.sortDescriptors = [sortDescriptor] + + // Edit the section name key path and cache name if appropriate. + // nil for section name key path means "no sections". + let aFetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: self.managedObjectContext!, sectionNameKeyPath: nil, cacheName: nil) + aFetchedResultsController.delegate = self + _fetchedResultsController = aFetchedResultsController + + do { + try _fetchedResultsController!.performFetch() + } catch { + // Replace this implementation with code to handle the error appropriately. + // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. + let nserror = error as NSError + fatalError("Unresolved error \(nserror), \(nserror.userInfo)") + } + + return _fetchedResultsController! + } + + func controllerWillChangeContent(_ controller: NSFetchedResultsController) { + self.tableView.beginUpdates() + } + + func controller(_ controller: NSFetchedResultsController, didChange sectionInfo: NSFetchedResultsSectionInfo, atSectionIndex sectionIndex: Int, for type: NSFetchedResultsChangeType) { + switch type { + case .insert: + self.tableView.insertSections(IndexSet(integer: sectionIndex), with: .fade) + case .delete: + self.tableView.deleteSections(IndexSet(integer: sectionIndex), with: .fade) + default: + return + } + } + + func controller(_ controller: NSFetchedResultsController, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) { + switch type { + case .insert: + tableView.insertRows(at: [newIndexPath!], with: .fade) + case .delete: + tableView.deleteRows(at: [indexPath!], with: .fade) + case .update: + self.configureCell(tableView.cellForRow(at: indexPath!)!, withEvent: anObject as! Event) + case .move: + tableView.moveRow(at: indexPath!, to: newIndexPath!) + } + } + + func controllerDidChangeContent(_ controller: NSFetchedResultsController) { + self.tableView.endUpdates() + } + +} diff --git a/Example/Sources/MasterViewController.swift b/Example/Sources/MasterViewController.swift new file mode 100644 index 00000000..0f3dfddb --- /dev/null +++ b/Example/Sources/MasterViewController.swift @@ -0,0 +1,117 @@ +// +// MasterViewController.swift +// CloudTest2 +// +// Created by Vasily Ulianov on 14.02.17. +// Copyright © 2017 Vasily Ulianov. All rights reserved. +// + +import UIKit +import CoreData +import CloudCore + +class MasterViewController: UITableViewController { + + var detailViewController: DetailViewController? = nil + var managedObjectContext: NSManagedObjectContext? = nil + + var _fetchedResultsController: NSFetchedResultsController? = nil + + // MARK: - CloudCore + + // Force refresh data from iCloud + @IBAction func refreshButton(_ sender: UIBarButtonItem) { + CloudCore.fetchAndSave(container: persistentContainer, error: { (error) in + print(error) + }) { + NSLog("Fetch from CloudKit completed") + } + } + + func insertNewObject(_ sender: Any) { + let context = self.fetchedResultsController.managedObjectContext + let newEvent = Event(context: context) + + // If appropriate, configure the new managed object. + newEvent.timestamp = NSDate() + newEvent.asset = UIImageJPEGRepresentation(#imageLiteral(resourceName: "TestImage"), 0.8) as NSData? + + let subevent = Subevent(context: context) + subevent.timestamp = NSDate() + subevent.event = newEvent + + try! context.save() + } + + // MARK: - UIViewController methods + + override func viewDidLoad() { + super.viewDidLoad() + + let addButton = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(insertNewObject(_:))) + self.navigationItem.rightBarButtonItem = addButton + if let split = self.splitViewController { + let controllers = split.viewControllers + self.detailViewController = (controllers[controllers.count-1] as! UINavigationController).topViewController as? DetailViewController + } + } + + override func viewWillAppear(_ animated: Bool) { + self.clearsSelectionOnViewWillAppear = self.splitViewController!.isCollapsed + super.viewWillAppear(animated) + } + + override func didReceiveMemoryWarning() { + super.didReceiveMemoryWarning() + // Dispose of any resources that can be recreated. + } + + // MARK: - Segues + + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + if segue.identifier == "showDetail" { + if let indexPath = self.tableView.indexPathForSelectedRow { + let object = self.fetchedResultsController.object(at: indexPath) + let controller = (segue.destination as! UINavigationController).topViewController as! DetailViewController + controller.detailItem = object + controller.navigationItem.leftBarButtonItem = self.splitViewController?.displayModeButtonItem + controller.navigationItem.leftItemsSupplementBackButton = true + } + } + } + + // MARK: - Table View + + override func numberOfSections(in tableView: UITableView) -> Int { + return self.fetchedResultsController.sections?.count ?? 0 + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + let sectionInfo = self.fetchedResultsController.sections![section] + return sectionInfo.numberOfObjects + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) + let event = self.fetchedResultsController.object(at: indexPath) + self.configureCell(cell, withEvent: event) + return cell + } + + override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { + return true + } + + override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCellEditingStyle, forRowAt indexPath: IndexPath) { + if editingStyle == .delete { + let context = self.fetchedResultsController.managedObjectContext + context.delete(self.fetchedResultsController.object(at: indexPath)) + try! context.save() + } + } + + func configureCell(_ cell: UITableViewCell, withEvent event: Event) { + cell.textLabel!.text = event.timestamp!.description + } +} + diff --git a/LICENSE.md b/LICENSE.md new file mode 100755 index 00000000..2eee9f31 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 Vasily Ulianov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Package.swift b/Package.swift new file mode 100644 index 00000000..b948d97f --- /dev/null +++ b/Package.swift @@ -0,0 +1,6 @@ +import PackageDescription + +let package = Package( + name: "CloudCore", + exclude: ["Tests"] +) diff --git a/README.md b/README.md old mode 100644 new mode 100755 index 8963d531..561b5a81 --- a/README.md +++ b/README.md @@ -1 +1,101 @@ -# CloudCore \ No newline at end of file +# CloudCore + +[![CI Status](http://img.shields.io/travis/sorix/CloudCore.svg?style=flat)](https://travis-ci.org/sorix/CloudCore) +[![Version](https://img.shields.io/cocoapods/v/CloudCore.svg?style=flat)](http://cocoadocs.org/docsets/CloudCore) +[![Platform](https://img.shields.io/cocoapods/p/CloudCore.svg?style=flat)](http://cocoadocs.org/docsets/CloudCore) +![Status](https://img.shields.io/badge/status-alpha-red.svg) +![Swift](https://img.shields.io/badge/swift-3.0-orange.svg) + +**CloudCore** is a framework that manages syncing between iCloud (CloudKit) and Core Data written at native Swift 3.0. + +#### Features +* Differential sync, only changed values in object are uploaded and downloaded +* Support of all relationship types +* Respect of Core Data options (cascade deletions, external storage options) +* Unit and performance tests for the most offline methods +* Private and shared CloudKit databases (to be tested) are supported + +## Installation + +### CocoaPods +**CloudCore** is available through [CocoaPods](http://cocoapods.org). To install +it, simply add the following line to your Podfile: + +```ruby +pod 'CloudCore' +``` + +### Swift Package Manager +The [Swift Package Manager](https://swift.org/package-manager/) is a tool for automating the distribution of Swift code and is integrated into the swift compiler. You can read more about package manager in [An Introduction to the Swift Package Manager](https://www.raywenderlich.com/148832/introduction-swift-package-manager) article. + +Once you have set up Swift package for your application, just add CloudCore as dependency at your `Package.swift`: + +```swift +dependencies: [ + .Package(url: "https://github.com/Sorix/CloudCore", majorVersion: 0) +] +``` + +## Quick start +1. Enable CloudKit capability for you application: +![CloudKit capability](https://cloud.githubusercontent.com/assets/5610904/25092841/28305bc0-2398-11e7-9fbf-f94c619c264f.png) + +2. Add 2 [service attributes](https://github.com/Sorix/CloudCore/wiki/Service-attributes) to each entity in CoreData model you want to sync: + * `recordData` attribute with `Binary` type + * `recordID` attribute with `String` type + +3. Make changes in your **AppDelegate.swift** file: + +```swift +func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { + // Register for push notifications about changes + UIApplication.shared.registerForRemoteNotifications() + + // Enable uploading changed local data to CoreData + CloudCore.observeCoreDataChanges(persistentContainer: self.persistentContainer, errorDelegate: nil) + + return true +} + +func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { + // Check if it CloudKit's and CloudCore notification + if CloudCore.isCloudCoreNotification(withUserInfo: userInfo) { + // Fetch changed data from iCloud + CloudCore.fetchAndSave(using: userInfo, container: self.persistentContainer, error: nil, completion: { (fetchResult) in + completionHandler(fetchResult.uiBackgroundFetchResult) + }) + } +} + +func applicationDidEnterBackground(_ application: UIApplication) { + // Save tokens on exit used to differential sync + CloudCore.tokens.saveToUserDefaults() +} +``` + +4. Make first run of your application in development environment, fill example data in Core Data and wait for syncing. CloudCore will create needed CloudKit schemes automatically. + +## Example application + +You can find example application at [Example](/Example/) directory. + +**How to run it:** +1. Change Bundle Identifier to anything else. +2. Check that embedded binaries has a correct path (you can remove and add again CloudCore.framework). +3. If you're using simulator, login at iCloud on it. + +**How to use it:** +* **+** button adds new object to local storage (that will be automatically synced to Cloud) +* **refresh** button calls `fetchAndSave` to fetch data from Cloud. That is useful button for simulators because Simulator unable to receive push notifications +* 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. + +## Roadmap + +- [ ] Sync with public CloudKit database (in development) +- [ ] Add tvOS support +- [ ] Increase number of tests +- [ ] Update documentation with macOS samples + +## Author + +Vasily Ulianov, vasily@me.com diff --git a/Resources/Info-Mac.plist b/Resources/Info-Mac.plist new file mode 100755 index 00000000..d3de8eef --- /dev/null +++ b/Resources/Info-Mac.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSPrincipalClass + + + diff --git a/Resources/Info-iOS.plist b/Resources/Info-iOS.plist new file mode 100755 index 00000000..d3de8eef --- /dev/null +++ b/Resources/Info-iOS.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSPrincipalClass + + + diff --git a/Sources/Classes/CloudCore.swift b/Sources/Classes/CloudCore.swift new file mode 100644 index 00000000..2a310428 --- /dev/null +++ b/Sources/Classes/CloudCore.swift @@ -0,0 +1,105 @@ +// +// CloudCore.swift +// CloudCore +// +// Created by Vasily Ulianov on 06.02.17. +// Copyright © 2017 Vasily Ulianov. All rights reserved. +// + +import CoreData +import CloudKit + +open class CloudCore { + public private(set) static var coreDataListener: CoreDataListener? + + public static var config = CloudCoreConfig() + public static var tokens = Tokens.loadFromUserDefaults() + + public typealias NotificationUserInfo = [AnyHashable : Any] + + /// Enable observing of changes at local database and saving them to iCloud + /// - note: if that method was never called before it automaticly invokes `FetchAndSave` operation to load initial data from CloudKit + public static func observeCoreDataChanges(persistentContainer: NSPersistentContainer, errorDelegate: CloudCoreErrorDelegate?) { + let errorBlock: ErrorBlock = { errorDelegate?.cloudCore(saveToCloudDidFailed: $0) } + + if !SetupOperation.isFinishedBefore { + fetchAndSave(container: persistentContainer, error: errorBlock, completion: nil) + } + + let listener = CoreDataListener(container: persistentContainer, errorBlock: errorBlock) + listener.observe() + self.coreDataListener = listener + } + + /// Remove oberserver that was created by `observeCoreDataChanges` method + public static func removeCoreDataObserver() { + coreDataListener?.stopObserving() + coreDataListener = nil + } + + /// Fetch changes from CloudKit database and save it to CoreData + /// + /// - Parameters: + /// - userInfo: notification's user info + /// - error: block will be called every time when error occurs during process + /// - completion: called after operation completion + /// - fetchResult: `FetchResult` enumeration with results of operation. Can be converted to `UIBackgroundFetchResult` to use in background fetch completion calls. + /// * .noData: if notification doesn't contain CloudCore's data, no fetching was done + /// * .failed: if any errors have occured during process that status will be set + /// * .newData: if data is fetched and saved successfully + public static func fetchAndSave(using userInfo: NotificationUserInfo, container: NSPersistentContainer, error: ErrorBlock?, completion: @escaping (_ fetchResult: FetchResult) -> Void) { + guard let cloudDatabase = self.database(for: userInfo) else { + completion(.noData) + return + } + + DispatchQueue.global(qos: .utility).async { + let errorProxy = ErrorBlockProxy(destination: error) + let operation = FetchAndSaveOperation(from: [cloudDatabase], persistentContainer: container) + operation.errorBlock = { errorProxy.send(error: $0) } + operation.start() + + if errorProxy.wasError { + completion(FetchResult.failed) + } else { + completion(FetchResult.newData) + } + } + } + + /// Fetch changes from all CloudKit databases and save it to Core Data + /// + /// - Parameters: + /// - error: block will be called every time when error occurs during process + /// - completion: called when fetching and saving are completed + public static func fetchAndSave(container: NSPersistentContainer, error: ErrorBlock?, completion: (() -> Void)?) { + DispatchQueue.global(qos: .utility).async { + let operation = FetchAndSaveOperation(persistentContainer: container) + operation.errorBlock = error + operation.completionBlock = completion + operation.start() + } + } + + /// Check if notification is CloudKit notification containing CloudCore data + /// + /// - Parameter userInfo: userInfo of notification + public static func isCloudCoreNotification(withUserInfo userInfo: NotificationUserInfo) -> Bool { + return (database(for: userInfo) != nil) + } + + static func database(for notificationUserInfo: NotificationUserInfo) -> CKDatabase? { + guard let notificationDictionary = notificationUserInfo as? [String: NSObject] else { return nil } + let notification = CKNotification(fromRemoteNotificationDictionary: notificationDictionary) + + guard let id = notification.subscriptionID else { return nil } + + switch id { + case config.subscriptionIDForPrivateDB: return CKContainer.default().privateCloudDatabase + case config.subscriptionIDForSharedDB: return CKContainer.default().sharedCloudDatabase + case _ where id.hasPrefix(config.publicSubscriptionIDPrefix): return CKContainer.default().publicCloudDatabase + default: return nil + } + } +} + diff --git a/Sources/Classes/ErrorBlockProxy.swift b/Sources/Classes/ErrorBlockProxy.swift new file mode 100644 index 00000000..454233cd --- /dev/null +++ b/Sources/Classes/ErrorBlockProxy.swift @@ -0,0 +1,26 @@ +// +// ErrorBlockProxy.swift +// CloudCore +// +// Created by Vasily Ulianov on 12.02.17. +// Copyright © 2017 Vasily Ulianov. All rights reserved. +// + +import Foundation + +/// Use that class to log if any errors were sent +class ErrorBlockProxy { + private(set) var wasError = false + var destination: ErrorBlock? + + init(destination: ErrorBlock?) { + self.destination = destination + } + + func send(error: Error?) { + if let error = error { + self.wasError = true + destination?(error) + } + } +} diff --git a/Sources/Classes/Fetch/FetchAndSaveOperation.swift b/Sources/Classes/Fetch/FetchAndSaveOperation.swift new file mode 100644 index 00000000..7b3e2edc --- /dev/null +++ b/Sources/Classes/Fetch/FetchAndSaveOperation.swift @@ -0,0 +1,120 @@ +// +// FetchAndSaveOperation.swift +// CloudCore +// +// Created by Vasily Ulianov on 13/03/2017. +// Copyright © 2017 Vasily Ulianov. All rights reserved. +// + +import CloudKit +import CoreData + +public class FetchAndSaveOperation: Operation { + + private static let allDatabases = [ +// CKContainer.default().publicCloudDatabase, + CKContainer.default().privateCloudDatabase, + CKContainer.default().sharedCloudDatabase + ] + + public typealias NotificationUserInfo = [AnyHashable : Any] + + private let tokens: Tokens + private let databases: [CKDatabase] + private let persistentContainer: NSPersistentContainer + + /// Called every time if error occurs + public var errorBlock: ErrorBlock? + + private let fetchOperationQueue = OperationQueue() + private let coreDataOperationQueue = OperationQueue() + + public init(from databases: [CKDatabase] = FetchAndSaveOperation.allDatabases, persistentContainer: NSPersistentContainer, tokens: Tokens = CloudCore.tokens) { + self.tokens = tokens + self.databases = databases + self.persistentContainer = persistentContainer + + fetchOperationQueue.name = "CloudCoreFetchFromCloud" + coreDataOperationQueue.name = "CloudCoreFetchFromCloud CoreData" + coreDataOperationQueue.maxConcurrentOperationCount = 1 + } + + override public func main() { + if self.isCancelled { return } + + // Check if CloudCore is initially setupped + if !SetupOperation.isFinishedBefore { + let setupOperation = SetupOperation() + setupOperation.errorBlock = errorBlock + setupOperation.start() + } + + NotificationCenter.default.post(name: .CloudCoreWillSyncFromCloud, object: nil) + + let backgroundContext = persistentContainer.newBackgroundContext() + backgroundContext.name = CloudCore.config.contextName + + for database in self.databases { + // It will subadd fetch and save operations to queue + self.addFetchDatabaseChangesOperation(from: database, using: backgroundContext) + } + + self.fetchOperationQueue.waitUntilAllOperationsAreFinished() + self.coreDataOperationQueue.waitUntilAllOperationsAreFinished() + + do { + if self.isCancelled { return } + try backgroundContext.save() + } catch { + errorBlock?(error) + } + + NotificationCenter.default.post(name: .CloudCoreDidSyncFromCloud, object: nil) + } + + public override func cancel() { + self.fetchOperationQueue.cancelAllOperations() + self.coreDataOperationQueue.cancelAllOperations() + + super.cancel() + } + + private func addFetchDatabaseChangesOperation(from database: CKDatabase, using context: NSManagedObjectContext) { + let databaseChangesOperation = FetchDatabaseChangesOperation(from: database, zoneName: CloudCore.config.zoneID.zoneName, tokens: tokens) + + databaseChangesOperation.fetchDatabaseChangesCompletionBlock = { recordZoneIDs, error in + if let error = error { + self.errorBlock?(error) + } else { + self.addRecordZoneChangesOperation(recordZoneIDs: recordZoneIDs, database: database, parentContext: context) + } + } + + fetchOperationQueue.addOperation(databaseChangesOperation) + } + + private func addRecordZoneChangesOperation(recordZoneIDs: [CKRecordZoneID], database: CKDatabase, parentContext: NSManagedObjectContext) { + if recordZoneIDs.isEmpty { return } + + let recordZoneChangesOperation = FetchRecordZoneChangesOperation(from: database, recordZoneIDs: recordZoneIDs, tokens: tokens) + + recordZoneChangesOperation.recordChangedBlock = { + // Convert and write CKRecord To NSManagedObject Operation + let convertOperation = RecordToCoreDataOperation(parentContext: parentContext, record: $0) + convertOperation.errorBlock = { self.errorBlock?($0) } + self.coreDataOperationQueue.addOperation(convertOperation) + } + + recordZoneChangesOperation.recordWithIDWasDeletedBlock = { + // Delete NSManagedObject with specified recordID Operation + let deleteOperation = DeleteFromCoreDataOperation(parentContext: parentContext, recordID: $0) + deleteOperation.errorBlock = { self.errorBlock?($0) } + self.coreDataOperationQueue.addOperation(deleteOperation) + } + + recordZoneChangesOperation.errorBlock = { self.errorBlock?($0) } + + fetchOperationQueue.addOperation(recordZoneChangesOperation) + } + +} diff --git a/Sources/Classes/Fetch/PublicSubscriptions/FetchPublicSubscriptionsOperation.swift b/Sources/Classes/Fetch/PublicSubscriptions/FetchPublicSubscriptionsOperation.swift new file mode 100644 index 00000000..5674acf7 --- /dev/null +++ b/Sources/Classes/Fetch/PublicSubscriptions/FetchPublicSubscriptionsOperation.swift @@ -0,0 +1,45 @@ +// +// FetchPublicSubscriptionsOperation.swift +// CloudCore +// +// Created by Vasily Ulianov on 14/03/2017. +// Copyright © 2017 Vasily Ulianov. All rights reserved. +// + +import CloudKit + +/// Fetch CloudCore's subscriptions from Public CKDatabase +class FetchPublicSubscriptionsOperation: AsynchronousOperation { + var errorBlock: ErrorBlock? + var fetchCompletionBlock: (([CKSubscription]) -> Void)? + + private let prefix = CloudCore.config.publicSubscriptionIDPrefix + + override func main() { + super.main() + + CKContainer.default().publicCloudDatabase.fetchAllSubscriptions { (subscriptions, error) in + defer { + self.state = .finished + } + + if let error = error { + self.errorBlock?(error) + return + } + + guard let subscriptions = subscriptions else { + self.fetchCompletionBlock?([CKSubscription]()) + return + } + + var cloudCoreSubscriptions = [CKSubscription]() + for subscription in subscriptions { + if !subscription.subscriptionID.hasPrefix(self.prefix) { continue } + cloudCoreSubscriptions.append(subscription) + } + + self.fetchCompletionBlock?(cloudCoreSubscriptions) + } + } +} diff --git a/Sources/Classes/Fetch/PublicSubscriptions/PublicDatabaseSubscriptions.swift b/Sources/Classes/Fetch/PublicSubscriptions/PublicDatabaseSubscriptions.swift new file mode 100644 index 00000000..48881790 --- /dev/null +++ b/Sources/Classes/Fetch/PublicSubscriptions/PublicDatabaseSubscriptions.swift @@ -0,0 +1,94 @@ +// +// PublicDatabaseSubscriptions.swift +// CloudCore +// +// Created by Vasily Ulianov on 13/03/2017. +// Copyright © 2017 Vasily Ulianov. All rights reserved. +// + +import CloudKit + +/// Use that class to manage subscriptions to public CloudKit database. +/// If you want to sync some records with public database you need to subsrcibe for notifications on that changes to enable iCloud -> Local database syncing. +public class PublicDatabaseSubscriptions { + + private static var userDefaultsKey: String { return CloudCore.config.userDefaultsKeyTokens } + private static var prefix: String { return CloudCore.config.publicSubscriptionIDPrefix } + + public internal(set) static var cachedIDs = UserDefaults.standard.stringArray(forKey: userDefaultsKey) ?? [String]() + + /// Create `CKQuerySubscription` for public database, use it if you want to enable syncing public iCloud -> Core Data + /// + /// - Parameters: + /// - recordType: The string that identifies the type of records to track. You are responsible for naming your app’s record types. This parameter must not be empty string. + /// - predicate: The matching criteria to apply to the records. This parameter must not be nil. For information about the operators that are supported in search predicates, see the discussion in [CKQuery](apple-reference-documentation://hsDjQFvil9). + /// - completion: returns subscriptionID and error upon operation completion + public static func subscribe(recordType: String, predicate: NSPredicate, completion: ((_ subscriptionID: String, _ error: Error?) -> Void)?) { + let id = prefix + UUID().uuidString + let subscription = CKQuerySubscription(recordType: recordType, predicate: predicate, subscriptionID: id, options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion]) + + let notificationInfo = CKNotificationInfo() + notificationInfo.shouldSendContentAvailable = true + subscription.notificationInfo = notificationInfo + + let operation = CKModifySubscriptionsOperation(subscriptionsToSave: [subscription], subscriptionIDsToDelete: []) + operation.modifySubscriptionsCompletionBlock = { _, _, error in + if error == nil { + self.cachedIDs.append(subscription.subscriptionID) + UserDefaults.standard.set(self.cachedIDs, forKey: self.userDefaultsKey) + UserDefaults.standard.synchronize() + } + + completion?(subscription.subscriptionID, error) + } + + operation.timeoutIntervalForResource = 20 + CKContainer.default().publicCloudDatabase.add(operation) + } + + /// Unsubscribe from public database + /// + /// - Parameters: + /// - subscriptionID: id of subscription to remove + public static func unsubscribe(subscriptionID: String, completion: ((Error?) -> Void)?) { + let operation = CKModifySubscriptionsOperation(subscriptionsToSave: [], subscriptionIDsToDelete: [subscriptionID]) + operation.modifySubscriptionsCompletionBlock = { _, _, error in + if error == nil { + if let index = self.cachedIDs.index(of: subscriptionID) { + self.cachedIDs.remove(at: index) + } + UserDefaults.standard.set(self.cachedIDs, forKey: self.userDefaultsKey) + UserDefaults.standard.synchronize() + } + + completion?(error) + } + + operation.timeoutIntervalForResource = 20 + CKContainer.default().publicCloudDatabase.add(operation) + } + + + /// Refresh local `cachedIDs` variable with actual data from CloudKit. + /// Recommended to use after application's UserDefaults reset. + /// + /// - Parameter completion: called upon operation completion, contains list of CloudCore subscriptions and error + public static func refreshCache(errorCompletion: ErrorBlock? = nil, successCompletion: (([CKSubscription]) -> Void)? = nil) { + let operation = FetchPublicSubscriptionsOperation() + operation.errorBlock = errorCompletion + operation.fetchCompletionBlock = { subscriptions in + self.setCache(from: subscriptions) + successCompletion?(subscriptions) + } + operation.start() + } + + internal static func setCache(from subscriptions: [CKSubscription]) { + let ids = subscriptions.map { $0.subscriptionID } + self.cachedIDs = ids + + UserDefaults.standard.set(ids, forKey: self.userDefaultsKey) + UserDefaults.standard.synchronize() + } +} + diff --git a/Sources/Classes/Fetch/SubOperations/AsynchronousOperation.swift b/Sources/Classes/Fetch/SubOperations/AsynchronousOperation.swift new file mode 100644 index 00000000..d3dc178d --- /dev/null +++ b/Sources/Classes/Fetch/SubOperations/AsynchronousOperation.swift @@ -0,0 +1,54 @@ +// +// AsynchronousOperation.swift +// CloudCore +// +// Created by Vasily Ulianov on 09.02.17. +// Copyright © 2017 Vasily Ulianov. All rights reserved. +// + +import Foundation + +/// Subclass of `Operation` that add support of asynchronous operations. +/// ## How to use: +/// 1. Call `super.main()` when override `main` method, call `super.start()` when override `start` method. +/// 2. When operation is finished or cancelled set `self.state = .finished` +open class AsynchronousOperation: Operation { + open override var isAsynchronous: Bool { return true } + open override var isExecuting: Bool { return state == .executing } + open override var isFinished: Bool { return state == .finished } + + public var state = State.ready { + willSet { + willChangeValue(forKey: state.keyPath) + willChangeValue(forKey: newValue.keyPath) + } + didSet { + didChangeValue(forKey: state.keyPath) + didChangeValue(forKey: oldValue.keyPath) + } + } + + public enum State: String { + case ready = "Ready" + case executing = "Executing" + case finished = "Finished" + fileprivate var keyPath: String { return "is" + self.rawValue } + } + + open override func start() { + if self.isCancelled { + state = .finished + } else { + state = .ready + main() + } + } + + open override func main() { + if self.isCancelled { + state = .finished + } else { + state = .executing + } + } +} diff --git a/Sources/Classes/Fetch/SubOperations/DeleteFromCoreDataOperation.swift b/Sources/Classes/Fetch/SubOperations/DeleteFromCoreDataOperation.swift new file mode 100644 index 00000000..ab33cd15 --- /dev/null +++ b/Sources/Classes/Fetch/SubOperations/DeleteFromCoreDataOperation.swift @@ -0,0 +1,77 @@ +// +// DeleteFromCoreDataOperation.swift +// CloudCore +// +// Created by Vasily Ulianov on 09.02.17. +// Copyright © 2017 Vasily Ulianov. All rights reserved. +// + +import CoreData +import CloudKit + +class DeleteFromCoreDataOperation: Operation { + let parentContext: NSManagedObjectContext + let recordID: CKRecordID + var errorBlock: ErrorBlock? + + init(parentContext: NSManagedObjectContext, recordID: CKRecordID) { + self.parentContext = parentContext + self.recordID = recordID + + super.init() + + self.name = "DeleteFromCoreDataOperation" + } + + override func main() { + if self.isCancelled { return } + + let childContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) + childContext.parent = parentContext + + // Iterate through each entity to fetch and delete object with our recordData + guard let entities = childContext.persistentStoreCoordinator?.managedObjectModel.entities else { return } + for entity in entities { + if self.isCancelled { return } + + guard let serviceAttributeNames = entity.serviceAttributeNames else { continue } + + do { + let deleted = try self.delete(entityName: serviceAttributeNames.entityName, + attributeNames: serviceAttributeNames, + in: childContext) + + // only 1 record with such recordData may exists, if delete we don't need to fetch other entities + if deleted { break } + } catch { + self.errorBlock?(error) + continue + } + } + + do { + try childContext.save() + } catch { + self.errorBlock?(error) + } + } + + /// Delete NSManagedObject with specified recordData from entity + /// + /// - Returns: `true` if object is found and deleted, `false` is object is not found + private func delete(entityName: String, attributeNames: ServiceAttributeNames, in context: NSManagedObjectContext) throws -> Bool { + let fetchRequest = NSFetchRequest(entityName: entityName) + fetchRequest.includesPropertyValues = false + fetchRequest.predicate = NSPredicate(format: attributeNames.recordID + " = %@", recordID.encodedString) + + guard let objects = try context.fetch(fetchRequest) as? [NSManagedObject] else { return false } + if objects.isEmpty { return false } + + for object in objects { + context.delete(object) + } + + return true + } + +} diff --git a/Sources/Classes/Fetch/SubOperations/FetchDatabaseChangesOperation.swift b/Sources/Classes/Fetch/SubOperations/FetchDatabaseChangesOperation.swift new file mode 100644 index 00000000..2375fdd7 --- /dev/null +++ b/Sources/Classes/Fetch/SubOperations/FetchDatabaseChangesOperation.swift @@ -0,0 +1,55 @@ +// +// FetchChangesOperation.swift +// CloudCore +// +// Created by Vasily Ulianov on 09.02.17. +// Copyright © 2017 Vasily Ulianov. All rights reserved. +// + +import CloudKit + +class FetchDatabaseChangesOperation: AsynchronousOperation { + // Set on init + let tokens: Tokens + let zoneName: String + let database: CKDatabase + // + + var fetchDatabaseChangesCompletionBlock: (([CKRecordZoneID], Error?) -> Void)? + + var changed = [CKRecordZoneID]() + + init(from database: CKDatabase, zoneName: String, tokens: Tokens) { + self.tokens = tokens + self.database = database + self.zoneName = zoneName + + super.init() + + self.name = "FetchDatabaseChangesOperation" + } + + override func main() { + super.main() + + let changesOperation = CKFetchDatabaseChangesOperation(previousServerChangeToken: tokens.serverChangeToken) + changesOperation.fetchAllChanges = true + changesOperation.recordZoneWithIDChangedBlock = { + if $0.zoneName != self.zoneName { return } + self.changed.append($0) + } + changesOperation.changeTokenUpdatedBlock = { self.tokens.serverChangeToken = $0 } + + // Fetch completed + changesOperation.fetchDatabaseChangesCompletionBlock = { + (newToken: CKServerChangeToken?, _, error: Error?) -> Void in + self.tokens.serverChangeToken = newToken + self.fetchDatabaseChangesCompletionBlock?(self.changed, error) + + self.state = .finished + } + + changesOperation.qualityOfService = self.qualityOfService + self.database.add(changesOperation) + } +} diff --git a/Sources/Classes/Fetch/SubOperations/FetchRecordZoneChangesOperation.swift b/Sources/Classes/Fetch/SubOperations/FetchRecordZoneChangesOperation.swift new file mode 100644 index 00000000..f28134ab --- /dev/null +++ b/Sources/Classes/Fetch/SubOperations/FetchRecordZoneChangesOperation.swift @@ -0,0 +1,64 @@ +// +// FetchRecordZoneChangesOperation.swift +// CloudCore +// +// Created by Vasily Ulianov on 09.02.17. +// Copyright © 2017 Vasily Ulianov. All rights reserved. +// + +import CloudKit + +class FetchRecordZoneChangesOperation: AsynchronousOperation { + // Set on init + let tokens: Tokens + let recordZoneIDs: [CKRecordZoneID] + let database: CKDatabase + // + + var errorBlock: ErrorBlock? + var recordChangedBlock: ((CKRecord) -> Void)? + var recordWithIDWasDeletedBlock: ((CKRecordID) -> Void)? + + init(from database: CKDatabase, recordZoneIDs: [CKRecordZoneID], tokens: Tokens) { + self.tokens = tokens + self.database = database + self.recordZoneIDs = recordZoneIDs + + super.init() + + self.name = "FetchRecordZoneChangesOperation" + } + + override func main() { + super.main() + + // Set tokens for zones + var optionsByRecordZoneID = [CKRecordZoneID: CKFetchRecordZoneChangesOptions]() + for zoneID in recordZoneIDs { + let options = CKFetchRecordZoneChangesOptions() + options.previousServerChangeToken = self.tokens.tokensByRecordZoneID[zoneID] + optionsByRecordZoneID[zoneID] = options + } + + // Init Fetch Operation + let fetchOperation = CKFetchRecordZoneChangesOperation(recordZoneIDs: recordZoneIDs, optionsByRecordZoneID: optionsByRecordZoneID) + + fetchOperation.recordChangedBlock = { self.recordChangedBlock?($0) } + fetchOperation.recordWithIDWasDeletedBlock = { self.recordWithIDWasDeletedBlock?($0.0) } + fetchOperation.recordZoneChangeTokensUpdatedBlock = { recordZoneID, serverChangeToken, _ in + self.tokens.tokensByRecordZoneID[recordZoneID] = serverChangeToken + } + + fetchOperation.fetchRecordZoneChangesCompletionBlock = { error in + if let error = error { + self.errorBlock?(error) + } + + self.state = .finished + } + + fetchOperation.qualityOfService = self.qualityOfService + fetchOperation.database = self.database + fetchOperation.start() + } +} diff --git a/Sources/Classes/Fetch/SubOperations/RecordToCoreDataOperation.swift b/Sources/Classes/Fetch/SubOperations/RecordToCoreDataOperation.swift new file mode 100644 index 00000000..df02cdc4 --- /dev/null +++ b/Sources/Classes/Fetch/SubOperations/RecordToCoreDataOperation.swift @@ -0,0 +1,87 @@ +// +// RecordToCoreDataOperation.swift +// CloudCore +// +// Created by Vasily Ulianov on 08.02.17. +// Copyright © 2017 Vasily Ulianov. All rights reserved. +// + +import CoreData +import CloudKit + +/// Convert CKRecord to NSManagedObject and save it to parent context, thread-safe +class RecordToCoreDataOperation: Operation { + let parentContext: NSManagedObjectContext + let record: CKRecord + var errorBlock: ErrorBlock? + + init(parentContext: NSManagedObjectContext, record: CKRecord) { + self.parentContext = parentContext + self.record = record + + super.init() + + self.name = "RecordToCoreDataOperation" + } + + override func main() { + if self.isCancelled { return } + + let childContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) + childContext.parent = parentContext + + do { + try self.setManagedObject(in: childContext) + try childContext.save() + } catch { + self.errorBlock?(error) + } + } + + /// Create or update existing NSManagedObject from CKRecord + /// + /// - Parameter context: child context to perform fetch operations + private func setManagedObject(in context: NSManagedObjectContext) throws { + let entityName = record.recordType + + guard let entity = NSEntityDescription.entity(forEntityName: entityName, in: context) else { + throw CloudCoreError.coreData("Unable to find entity specified in CKRecord: " + entityName) + } + guard let serviceAttributes = NSEntityDescription.entity(forEntityName: entityName, in: context)?.serviceAttributeNames else { + throw CloudCoreError.missingServiceAttributes(entityName: entityName) + } + + // Try to find existing objects + let fetchRequest = NSFetchRequest(entityName: entityName) + fetchRequest.predicate = NSPredicate(format: serviceAttributes.recordID + " == %@", record.recordID.encodedString) + + if let foundObject = try context.fetch(fetchRequest).first as? NSManagedObject { + try fill(object: foundObject, entityName: entityName, serviceAttributeNames: serviceAttributes, context: context) + } else { + let newObject = NSManagedObject(entity: entity, insertInto: context) + try fill(object: newObject, entityName: entityName, serviceAttributeNames: serviceAttributes, context: context) + } + } + + + /// Fill provided `NSManagedObject` with data + /// + /// - Parameters: + /// - entityName: entity name of `object` + /// - recordDataAttributeName: attribute name containing recordData + private func fill(object: NSManagedObject, entityName: String, serviceAttributeNames: ServiceAttributeNames, context: NSManagedObjectContext) throws { + for key in record.allKeys() { + if self.isCancelled { return } + + let recordValue = record.value(forKey: key) + + let attribute = CloudKitAttribute(value: recordValue, entityName: entityName, serviceAttributes: serviceAttributeNames, context: context) + let coreDataValue = try attribute.makeCoreDataValue() + object.setValue(coreDataValue, forKey: key) + } + + // Set system headers + object.setValue(record.recordID.encodedString, forKey: serviceAttributeNames.recordID) + object.setValue(record.encdodedSystemFields, forKey: serviceAttributeNames.recordData) + } +} diff --git a/Sources/Classes/SetupOperation.swift b/Sources/Classes/SetupOperation.swift new file mode 100644 index 00000000..9b37a0f8 --- /dev/null +++ b/Sources/Classes/SetupOperation.swift @@ -0,0 +1,109 @@ +// +// SetupOperation.swift +// CloudCore +// +// Created by Vasily Ulianov on 19/03/2017. +// Copyright © 2017 Vasily Ulianov. All rights reserved. +// + +import Foundation +import CloudKit + +public class SetupOperation: Operation { + // MARK: - Is setup performed + public static var isFinishedBefore: Bool { + get { + if let cachedValue = _isFinishedBefore { return cachedValue } + + let defaultsValue = UserDefaults.standard.bool(forKey: CloudCore.config.userDefaultsKeyIsSetuped) + _isFinishedBefore = defaultsValue + return defaultsValue + } + set { + _isFinishedBefore = newValue + UserDefaults.standard.set(newValue, forKey: CloudCore.config.userDefaultsKeyIsSetuped) + UserDefaults.standard.synchronize() + } + } + + static private var _isFinishedBefore: Bool? + + // MARK: - Operation + private let queue = OperationQueue() + + override public var qualityOfService: QualityOfService { + didSet { + queue.qualityOfService = qualityOfService + } + } + + public var errorBlock: ErrorBlock? + + override public func main() { + if self.isCancelled { return } + + // Create zone + let createZone = self.createZonesOperation(withNames: [CloudCore.config.zoneID.zoneName]) + + // Subscriptions + let container = CKContainer.default() + let subcribePrivate = self.databaseSubscriptionOperation(database: container.privateCloudDatabase, id: CloudCore.config.subscriptionIDForPrivateDB) + let subcribeShared = self.databaseSubscriptionOperation(database: container.sharedCloudDatabase, id: CloudCore.config.subscriptionIDForSharedDB) + + let fetchPublicSubscriptions = FetchPublicSubscriptionsOperation() + fetchPublicSubscriptions.errorBlock = errorBlock + fetchPublicSubscriptions.fetchCompletionBlock = { PublicDatabaseSubscriptions.setCache(from: $0) } + + queue.addOperations([createZone, subcribePrivate, subcribeShared, fetchPublicSubscriptions], waitUntilFinished: true) + + SetupOperation.isFinishedBefore = true + } + + /// Create new record zones with specified names + private func createZonesOperation(withNames names: [String]) -> CKModifyRecordZonesOperation { + assert(!names.isEmpty, "List of zones is empty") + + var newZones = [CKRecordZone]() + for name in names { + newZones += [CKRecordZone(zoneName: name)] + } + + let recordZoneOperation = CKModifyRecordZonesOperation(recordZonesToSave: newZones, recordZoneIDsToDelete: nil) + recordZoneOperation.modifyRecordZonesCompletionBlock = { + if let error = $2 { + self.errorBlock?(error) + } + } + + recordZoneOperation.timeoutIntervalForResource = 20 + recordZoneOperation.database = CKContainer.default().privateCloudDatabase + + return recordZoneOperation + } + + /// Subscribe to all changes at CloudKit Private & Shared databases + private func databaseSubscriptionOperation(database: CKDatabase, id: String) -> CKModifySubscriptionsOperation { + let notificationInfo = CKNotificationInfo() + notificationInfo.shouldSendContentAvailable = true + + let subscription = CKDatabaseSubscription(subscriptionID: id) + subscription.notificationInfo = notificationInfo + + let operation = CKModifySubscriptionsOperation(subscriptionsToSave: [subscription], subscriptionIDsToDelete: []) + operation.modifySubscriptionsCompletionBlock = { + if let error = $2 { + self.errorBlock?(error) + } + } + + operation.timeoutIntervalForResource = 20 + operation.database = database + + return operation + } + + public override func cancel() { + self.queue.cancelAllOperations() + super.cancel() + } +} diff --git a/Sources/Classes/Upload/CloudSaveOperationQueue.swift b/Sources/Classes/Upload/CloudSaveOperationQueue.swift new file mode 100644 index 00000000..b9c99f24 --- /dev/null +++ b/Sources/Classes/Upload/CloudSaveOperationQueue.swift @@ -0,0 +1,111 @@ +// +// CloudSaveController.swift +// CloudCore +// +// Created by Vasily Ulianov on 06.02.17. +// Copyright © 2017 Vasily Ulianov. All rights reserved. +// + +import CloudKit + +class CloudSaveOperationQueue: OperationQueue { + var errorBlock: ErrorBlock? + + /// Modify CloudKit database, operations will be created and added to operation queue. + func addOperations(recordsToSave: [RecordWithDatabase], recordIDsToDelete: [RecordIDWithDatabase]) { + var datasource = [DatabaseModifyDataSource]() + + // Split records to save to databases + for recordToSave in recordsToSave { + if let modifier = datasource.find(database: recordToSave.database) { + modifier.save.append(recordToSave.record) + } else { + let newModifier = DatabaseModifyDataSource(database: recordToSave.database) + newModifier.save.append(recordToSave.record) + datasource.append(newModifier) + } + } + + // Split record ids to delete to databases + for idToDelete in recordIDsToDelete { + if let modifier = datasource.find(database: idToDelete.database) { + modifier.delete.append(idToDelete.recordID) + } else { + let newModifier = DatabaseModifyDataSource(database: idToDelete.database) + newModifier.delete.append(idToDelete.recordID) + datasource.append(newModifier) + } + } + + let initialSetupOperation = makeSetupOperationIfNeeded() + + // Perform + for databaseModifier in datasource { + addOperation(recordsToSave: databaseModifier.save, recordIDsToDelete: databaseModifier.delete, database: databaseModifier.database, dependency: initialSetupOperation) + } + } + + /// - Returns: `SetupOperation` if setup wasn't performed before otherwise `nil` will be returned + func makeSetupOperationIfNeeded() -> SetupOperation? { + if SetupOperation.isFinishedBefore { return nil } + + let setupOperation = SetupOperation() + setupOperation.errorBlock = errorBlock + return setupOperation + } + + private func addOperation(recordsToSave: [CKRecord], recordIDsToDelete: [CKRecordID], database: CKDatabase, dependency: Operation?) { + // Modify CKRecord Operation + let modifyOperation = CKModifyRecordsOperation(recordsToSave: recordsToSave, recordIDsToDelete: recordIDsToDelete) + + modifyOperation.perRecordCompletionBlock = { record, error in + if let error = error { + self.errorBlock?(error) + } else { + self.removeCachedAssets(for: record) + } + } + + modifyOperation.modifyRecordsCompletionBlock = { [weak self] savedRecords, _, error in + if let error = error { + self?.errorBlock?(error) + } + } + + modifyOperation.database = database + + if let dependency = dependency { + modifyOperation.addDependency(dependency) + } + + self.addOperation(modifyOperation) + } + + /// Remove locally cached assets prepared for uploading at CloudKit + private func removeCachedAssets(for record: CKRecord) { + for key in record.allKeys() { + guard let asset = record.value(forKey: key) as? CKAsset else { continue } + try? FileManager.default.removeItem(at: asset.fileURL) + } + } +} + +private class DatabaseModifyDataSource { + let database: CKDatabase + var save = [CKRecord]() + var delete = [CKRecordID]() + + init(database: CKDatabase) { + self.database = database + } +} + +extension Sequence where Iterator.Element == DatabaseModifyDataSource { + func find(database: CKDatabase) -> DatabaseModifyDataSource? { + for element in self { + if element.database == database { return element } + } + + return nil + } +} diff --git a/Sources/Classes/Upload/CoreDataListener.swift b/Sources/Classes/Upload/CoreDataListener.swift new file mode 100644 index 00000000..6ba788ee --- /dev/null +++ b/Sources/Classes/Upload/CoreDataListener.swift @@ -0,0 +1,77 @@ +// +// CoreDataChangesListener.swift +// CloudCore +// +// Created by Vasily Ulianov on 02.02.17. +// Copyright © 2017 Vasily Ulianov. All rights reserved. +// + +import Foundation +import CoreData + +/// Class responsible for taking action on Core Data save notifications +open class CoreDataListener { + var container: NSPersistentContainer + + let converter = ObjectToRecordConverter() + let cloudSaveOperationQueue = CloudSaveOperationQueue() + + let cloudContextName = "CloudCoreSync" + + public init(container: NSPersistentContainer, errorBlock: ErrorBlock?) { + self.container = container + converter.errorBlock = errorBlock + cloudSaveOperationQueue.errorBlock = errorBlock + } + + /// Observe Core Data willSave and didSave notifications + open func observe() { + NotificationCenter.default.addObserver(self, selector: #selector(self.willSave(notification:)), name: .NSManagedObjectContextWillSave, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(self.didSave(notification:)), name: .NSManagedObjectContextDidSave, object: nil) + } + + /// Remove Core Data observers + public func stopObserving() { + NotificationCenter.default.removeObserver(self) + } + + deinit { + stopObserving() + } + + @objc private func willSave(notification: Notification) { + guard let context = notification.object as? NSManagedObjectContext else { return } + + // Ignore saves that are generated by FetchAndSaveController + if context.name == CloudCore.config.contextName { return } + + // Upload only for changes in root context that will be saved to persistentStore + if context.parent != nil { return } + + converter.setUnconfirmedOperations(inserted: context.insertedObjects, + updated: context.updatedObjects, + deleted: context.deletedObjects) + } + + @objc private func didSave(notification: Notification) { + guard let context = notification.object as? NSManagedObjectContext else { return } + if context.name == CloudCore.config.contextName { return } + if context.parent != nil { return } + + if converter.notConfirmedConvertOperations.isEmpty && converter.recordIDsToDelete.isEmpty { return } + + DispatchQueue.global(qos: .utility).async { [weak self] in + guard let listener = self else { return } + NotificationCenter.default.post(name: .CloudCoreWillSyncToCloud, object: nil) + + let backgroundContext = listener.container.newBackgroundContext() + backgroundContext.name = listener.cloudContextName + + let records = listener.converter.confirmConvertOperationsAndWait(in: backgroundContext) + listener.cloudSaveOperationQueue.addOperations(recordsToSave: records.recordsToSave, recordIDsToDelete: records.recordIDsToDelete) + listener.cloudSaveOperationQueue.waitUntilAllOperationsAreFinished() + + NotificationCenter.default.post(name: .CloudCoreDidSyncToCloud, object: nil) + } + } +} diff --git a/Sources/Classes/Upload/Model/RecordIDWithDatabase.swift b/Sources/Classes/Upload/Model/RecordIDWithDatabase.swift new file mode 100644 index 00000000..b802e464 --- /dev/null +++ b/Sources/Classes/Upload/Model/RecordIDWithDatabase.swift @@ -0,0 +1,19 @@ +// +// RecordIDWithDatabase.swift +// CloudCore +// +// Created by Vasily Ulianov on 13/03/2017. +// Copyright © 2017 Vasily Ulianov. All rights reserved. +// + +import CloudKit + +class RecordIDWithDatabase { + let recordID: CKRecordID + let database: CKDatabase + + init(_ recordID: CKRecordID, _ database: CKDatabase) { + self.recordID = recordID + self.database = database + } +} diff --git a/Sources/Classes/Upload/Model/RecordWithDatabase.swift b/Sources/Classes/Upload/Model/RecordWithDatabase.swift new file mode 100644 index 00000000..0b6fc2b0 --- /dev/null +++ b/Sources/Classes/Upload/Model/RecordWithDatabase.swift @@ -0,0 +1,19 @@ +// +// RecordWithDatabase.swift +// CloudCore +// +// Created by Vasily Ulianov on 13/03/2017. +// Copyright © 2017 Vasily Ulianov. All rights reserved. +// + +import CloudKit + +class RecordWithDatabase { + let record: CKRecord + let database: CKDatabase + + init(_ record: CKRecord, _ database: CKDatabase) { + self.record = record + self.database = database + } +} diff --git a/Sources/Classes/Upload/ObjectToRecord/CoreDataAttribute.swift b/Sources/Classes/Upload/ObjectToRecord/CoreDataAttribute.swift new file mode 100644 index 00000000..cf7dd05f --- /dev/null +++ b/Sources/Classes/Upload/ObjectToRecord/CoreDataAttribute.swift @@ -0,0 +1,71 @@ +// +// CoreDataAttribute.swift +// CloudCore +// +// Created by Vasily Ulianov on 02.02.17. +// Copyright © 2017 Vasily Ulianov. All rights reserved. +// + +import CoreData +import CloudKit + +class CoreDataAttribute { + typealias Class = CoreDataAttribute + + let name: String + let value: Any? + let description: NSAttributeDescription + + /// Initialize Core Data Attribute with properties and value + /// - Returns: `nil` if it is not an attribute (possible it is relationship?) + init?(value: Any?, attributeName: String, entity: NSEntityDescription) { + guard let description = CoreDataAttribute.attributeDescription(for: attributeName, in: entity) else { + // it is not an attribute + return nil + } + + self.description = description + + if value is NSNull { + self.value = nil + } else { + self.value = value + } + + self.name = attributeName + } + + private static func attributeDescription(for lookupName: String, in entity: NSEntityDescription) -> NSAttributeDescription? { + for (name, description) in entity.attributesByName { + if lookupName == name { return description } + } + + return nil + } + + /// Return value in CloudKit-friendly format that is usable in CKRecord + /// - note: Possible long operation (if attribute has binary data asset maybe created) + func makeRecordValue() throws -> Any? { + switch self.description.attributeType { + case .binaryDataAttributeType: + guard let binaryData = self.value as? Data else { + return nil + } + + if binaryData.count > 1024*1024 || description.allowsExternalBinaryDataStorage { + return try Class.createAsset(for: binaryData) + } else { + return binaryData + } + default: return self.value + } + } + + static func createAsset(for data: Data) throws -> CKAsset { + let fileName = UUID().uuidString.lowercased() + ".bin" + let fullURL = URL(fileURLWithPath: fileName, relativeTo: FileManager.default.temporaryDirectory) + + try data.write(to: fullURL) + return CKAsset(fileURL: fullURL) + } +} diff --git a/Sources/Classes/Upload/ObjectToRecord/CoreDataRelationship.swift b/Sources/Classes/Upload/ObjectToRecord/CoreDataRelationship.swift new file mode 100644 index 00000000..a8f1beb1 --- /dev/null +++ b/Sources/Classes/Upload/ObjectToRecord/CoreDataRelationship.swift @@ -0,0 +1,79 @@ +// +// CoreDataRelationship.swift +// CloudCore +// +// Created by Vasily Ulianov on 04.02.17. +// Copyright © 2017 Vasily Ulianov. All rights reserved. +// + +import CoreData +import CloudKit + +class CoreDataRelationship { + typealias Class = CoreDataRelationship + + let value: Any + let description: NSRelationshipDescription + + /// Initialize Core Data Attribute with properties and value + /// - Returns: `nil` if it is not an attribute (possible it is relationship?) + init?(value: Any, relationshipName: String, entity: NSEntityDescription) { + guard let description = Class.relationshipDescription(for: relationshipName, in: entity) else { + // it is not a relationship + return nil + } + + self.description = description + self.value = value + } + + private static func relationshipDescription(for lookupName: String, in entity: NSEntityDescription) -> NSRelationshipDescription? { + for (name, description) in entity.relationshipsByName { + if lookupName == name { return description } + } + + return nil + } + + /// Make reference(s) for relationship + /// + /// - Returns: `CKReference` or `[CKReference]` + func makeRecordValue() throws -> Any? { + if self.description.isToMany { + guard let objectsSet = value as? NSSet else { return nil } + + var referenceList = [CKReference]() + for (_, managedObject) in objectsSet.enumerated() { + guard let managedObject = managedObject as? NSManagedObject, + let reference = try makeReference(from: managedObject) else { continue } + + referenceList.append(reference) + } + + return referenceList + } else { + guard let object = value as? NSManagedObject else { return nil } + + return try makeReference(from: object) + } + } + + private func makeReference(from managedObject: NSManagedObject) throws -> CKReference? { + let action: CKReferenceAction + if case .some(NSDeleteRule.cascadeDeleteRule) = description.inverseRelationship?.deleteRule { + action = .deleteSelf + } else { + action = .none + } + + guard let record = try managedObject.restoreRecordWithSystemFields() else { + // That is possible if method is called before all managed object were filled with recordData + // That may cause possible reference corruption (Core Data -> iCloud), but it is not critical + assertionFailure("Managed Object doesn't have stored record information, should be reported as a framework bug") + return nil + } + + return CKReference(record: record, action: action) + } + +} diff --git a/Sources/Classes/Upload/ObjectToRecord/ObjectToRecordConverter.swift b/Sources/Classes/Upload/ObjectToRecord/ObjectToRecordConverter.swift new file mode 100644 index 00000000..a22546aa --- /dev/null +++ b/Sources/Classes/Upload/ObjectToRecord/ObjectToRecordConverter.swift @@ -0,0 +1,121 @@ +// +// ObjectToRecordConverter.swift +// CloudCore +// +// Created by Vasily Ulianov on 09.02.17. +// Copyright © 2017 Vasily Ulianov. All rights reserved. +// + +import CoreData +import CloudKit + +class ObjectToRecordConverter { + enum ManagedObjectChangeType { + case inserted, updated + } + + var errorBlock: ErrorBlock? + + private(set) var notConfirmedConvertOperations = [ObjectToRecordOperation]() + private let operationQueue = OperationQueue() + + private var convertedRecords = [RecordWithDatabase]() + private(set) var recordIDsToDelete = [RecordIDWithDatabase]() + + func setUnconfirmedOperations(inserted: Set, updated: Set, deleted: Set) { + self.notConfirmedConvertOperations = self.convertOperations(from: inserted, changeType: .inserted) + self.notConfirmedConvertOperations += self.convertOperations(from: updated, changeType: .updated) + + self.recordIDsToDelete = convert(deleted: deleted) + } + + private func convertOperations(from objectSet: Set, changeType: ManagedObjectChangeType) -> [ObjectToRecordOperation] { + var operations = [ObjectToRecordOperation]() + + for object in objectSet { + // Ignore entities that doesn't have required service attributes + guard let serviceAttributeNames = object.entity.serviceAttributeNames else { continue } + + do { + let recordWithSystemFields = try object.setRecordInformation() + var changedAttributes: [String]? + + // Save changes keys only for updated object, for inserted objects full sync will be used + if case .updated = changeType { changedAttributes = Array(object.changedValues().keys) } + + let convertOperation = ObjectToRecordOperation(record: recordWithSystemFields, + changedAttributes: changedAttributes, + serviceAttributeNames: serviceAttributeNames) + + convertOperation.errorCompletionBlock = { [weak self] error in + self?.errorBlock?(error) + } + + convertOperation.conversionCompletionBlock = { [weak self] record in + guard let me = self else { return } + + let cloudDatabase = me.database(for: record.recordID, serviceAttributes: serviceAttributeNames) + let recordWithDB = RecordWithDatabase(record, cloudDatabase) + me.convertedRecords.append(recordWithDB) + } + + operations.append(convertOperation) + } catch { + errorBlock?(error) + } + } + + return operations + } + + private func convert(deleted objectSet: Set) -> [RecordIDWithDatabase] { + var recordIDs = [RecordIDWithDatabase]() + + for object in objectSet { + if let triedRestoredRecord = try? object.restoreRecordWithSystemFields(), + let restoredRecord = triedRestoredRecord, + let serviceAttributeNames = object.entity.serviceAttributeNames { + let database = self.database(for: restoredRecord.recordID, serviceAttributes: serviceAttributeNames) + let recordIDWithDB = RecordIDWithDatabase(restoredRecord.recordID, database) + recordIDs.append(recordIDWithDB) + } + } + + return recordIDs + } + + /// Add all uncofirmed operations to operation queue + /// - attention: Don't call this method from same context's `perfom`, that will cause deadlock + func confirmConvertOperationsAndWait(in context: NSManagedObjectContext) -> (recordsToSave: [RecordWithDatabase], recordIDsToDelete: [RecordIDWithDatabase]) { + for operation in notConfirmedConvertOperations { + operation.parentContext = context + operationQueue.addOperation(operation) + } + + notConfirmedConvertOperations = [ObjectToRecordOperation]() + operationQueue.waitUntilAllOperationsAreFinished() + + let recordsToSave = self.convertedRecords + let recordIDsToDelete = self.recordIDsToDelete + + self.convertedRecords = [RecordWithDatabase]() + self.recordIDsToDelete = [RecordIDWithDatabase]() + + return (recordsToSave, recordIDsToDelete) + } + + /// Get appropriate database for modify operations + private func database(for recordID: CKRecordID, serviceAttributes: ServiceAttributeNames) -> CKDatabase { + let container = CKContainer.default() + + if serviceAttributes.isPublic { return container.publicCloudDatabase } + + let ownerName = recordID.zoneID.ownerName + + if ownerName == CKCurrentUserDefaultName { + return container.privateCloudDatabase + } else { + return container.sharedCloudDatabase + } + } +} diff --git a/Sources/Classes/Upload/ObjectToRecord/ObjectToRecordOperation.swift b/Sources/Classes/Upload/ObjectToRecord/ObjectToRecordOperation.swift new file mode 100644 index 00000000..8c177b4d --- /dev/null +++ b/Sources/Classes/Upload/ObjectToRecord/ObjectToRecordOperation.swift @@ -0,0 +1,83 @@ +// +// ObjectToRecordOperation.swift +// CloudCore +// +// Created by Vasily Ulianov on 09.02.17. +// Copyright © 2017 Vasily Ulianov. All rights reserved. +// + +import CloudKit +import CoreData + +class ObjectToRecordOperation: Operation { + /// Need to set before starting operation, child context from it will be created + var parentContext: NSManagedObjectContext? + + // Set on init + let record: CKRecord + private let changedAttributes: [String]? + private let serviceAttributeNames: ServiceAttributeNames + // + + var errorCompletionBlock: ((Error) -> Void)? + var conversionCompletionBlock: ((CKRecord) -> Void)? + + init(record: CKRecord, changedAttributes: [String]?, serviceAttributeNames: ServiceAttributeNames) { + self.record = record + self.changedAttributes = changedAttributes + self.serviceAttributeNames = serviceAttributeNames + + super.init() + self.name = "ObjectToRecordOperation" + } + + override func main() { + if self.isCancelled { return } + guard let parentContext = parentContext else { + let error = CloudCoreError.coreData("CloudCore framework error") + errorCompletionBlock?(error) + return + } + + let childContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) + childContext.parent = parentContext + + do { + try self.fillRecordWithData(using: childContext) + try childContext.save() + self.conversionCompletionBlock?(self.record) + } catch { + self.errorCompletionBlock?(error) + } + } + + private func fillRecordWithData(using context: NSManagedObjectContext) throws { + guard let managedObject = try fetchObject(for: record, using: context) else { + throw CloudCoreError.coreData("Unable to find managed object for record: \(record)") + } + + let changedValues = managedObject.committedValues(forKeys: changedAttributes) + + for (attributeName, value) in changedValues { + if self.isCancelled { return } + if attributeName == serviceAttributeNames.recordData || attributeName == serviceAttributeNames.recordID { continue } + + if let attribute = CoreDataAttribute(value: value, attributeName: attributeName, entity: managedObject.entity) { + let recordValue = try attribute.makeRecordValue() + record.setValue(recordValue, forKey: attributeName) + } else if let relationship = CoreDataRelationship(value: value, relationshipName: attributeName, entity: managedObject.entity) { + let references = try relationship.makeRecordValue() + record.setValue(references, forKey: attributeName) + } + } + } + + private func fetchObject(for record: CKRecord, using context: NSManagedObjectContext) throws -> NSManagedObject? { + let entityName = record.recordType + + let fetchRequest = NSFetchRequest(entityName: entityName) + fetchRequest.predicate = NSPredicate(format: serviceAttributeNames.recordID + " == %@", record.recordID.encodedString) + + return try context.fetch(fetchRequest).first as? NSManagedObject + } +} diff --git a/Sources/Enum/CloudCoreError.swift b/Sources/Enum/CloudCoreError.swift new file mode 100644 index 00000000..ccd47e26 --- /dev/null +++ b/Sources/Enum/CloudCoreError.swift @@ -0,0 +1,32 @@ +// +// CloudCoreError.swift +// CloudCore +// +// Created by Vasily Ulianov on 02.02.17. +// Copyright © 2017 Vasily Ulianov. All rights reserved. +// + +import Foundation +import CoreData + +public enum CloudCoreError: Error, CustomStringConvertible { + case missingServiceAttributes(entityName: String?) + case cloudKit(String) + case coreData(String) + case custom(String) + + public var localizedDescription: String { + switch self { + case .missingServiceAttributes(let entity): + let entityName = entity ?? "UNKNOWN_ENTITY" + return entityName + " doesn't contain all required services attributes" + case .cloudKit(let text): return "iCloud error: \(text)" + case .coreData(let text): return "Core Data error: \(text)" + case .custom(let error): return error + } + } + + public var description: String { return self.localizedDescription } +} + +public typealias ErrorBlock = (Error) -> Void diff --git a/Sources/Enum/FetchResult.swift b/Sources/Enum/FetchResult.swift new file mode 100644 index 00000000..8fcf8b48 --- /dev/null +++ b/Sources/Enum/FetchResult.swift @@ -0,0 +1,25 @@ +// +// FetchResult.swift +// CloudCore +// +// Created by Vasily Ulianov on 08.02.17. +// Copyright © 2017 Vasily Ulianov. All rights reserved. +// + +import Foundation + +public enum FetchResult: UInt { + case newData = 0 + case noData = 1 + case failed = 2 +} + +#if os(iOS) + import UIKit + + public extension FetchResult { + public var uiBackgroundFetchResult: UIBackgroundFetchResult { + return UIBackgroundFetchResult(rawValue: self.rawValue)! + } + } +#endif diff --git a/Sources/Extensions/CKRecordID.swift b/Sources/Extensions/CKRecordID.swift new file mode 100644 index 00000000..da63c85a --- /dev/null +++ b/Sources/Extensions/CKRecordID.swift @@ -0,0 +1,32 @@ +// +// CloudRecordID.swift +// CloudCore +// +// Created by Vasily Ulianov on 02.02.17. +// Copyright © 2017 Vasily Ulianov. All rights reserved. +// + +import CloudKit + +extension CKRecordID { + private static let separator = "|" + + /// Init from encoded string + /// + /// - Parameter encodedString: format: `recordName|ownerName` + convenience init?(encodedString: String) { + let separated = encodedString.components(separatedBy: CKRecordID.separator) + + if separated.count == 2 { + let zoneID = CKRecordZoneID(zoneName: CloudCore.config.zoneID.zoneName, ownerName: separated[1]) + self.init(recordName: separated[0], zoneID: zoneID) + } else { + return nil + } + } + + /// Encoded string in format: `recordName|ownerName` + var encodedString: String { + return recordName + CKRecordID.separator + zoneID.ownerName + } +} diff --git a/Sources/Extensions/NSEntityDescription.swift b/Sources/Extensions/NSEntityDescription.swift new file mode 100644 index 00000000..776b2b5b --- /dev/null +++ b/Sources/Extensions/NSEntityDescription.swift @@ -0,0 +1,78 @@ +// +// NSEntityDescription.swift +// CloudCore +// +// Created by Vasily Ulianov on 07.02.17. +// Copyright © 2017 Vasily Ulianov. All rights reserved. +// + +import CoreData + +extension NSEntityDescription { + var serviceAttributeNames: ServiceAttributeNames? { + guard let entityName = self.name else { return nil } + + let attributeNamesFromUserInfo = self.parseAttributeNamesFromUserInfo() + + // Get required attributes + + // Record Data + let recordDataName: String + if let recordDataUserInfoName = attributeNamesFromUserInfo.recordData { + recordDataName = recordDataUserInfoName + } else { + // Last chance: try to find default attribute name in entity + if self.attributesByName.keys.contains(CloudCore.config.defaultAttributeNameRecordData) { + recordDataName = CloudCore.config.defaultAttributeNameRecordData + } else { + return nil + } + } + + // Record ID + let recordIDName: String + if let recordIDUserInfoName = attributeNamesFromUserInfo.recordID { + recordIDName = recordIDUserInfoName + } else { + // Last chance: try to find default attribute name in entity + if self.attributesByName.keys.contains(CloudCore.config.defaultAttributeNameRecordID) { + recordIDName = CloudCore.config.defaultAttributeNameRecordID + } else { + return nil + } + } + + return ServiceAttributeNames(entityName: entityName, recordData: recordDataName, recordID: recordIDName, isPublic: attributeNamesFromUserInfo.isPublic) + } + + /// Parse data from User Info dictionary + private func parseAttributeNamesFromUserInfo() -> (isPublic: Bool, recordData: String?, recordID: String?) { + var recordDataName: String? + var recordIDName: String? + var isPublic = false + + // In attribute + for (attributeName, attributeDescription) in self.attributesByName { + guard let userInfo = attributeDescription.userInfo else { continue } + + // In userInfo dictionary + for (key, value) in userInfo { + guard let key = key as? String, + let value = value as? String else { continue } + + if key == ServiceAttributeNames.keyType { + switch value { + case ServiceAttributeNames.valueRecordID: recordIDName = attributeName + case ServiceAttributeNames.valueRecordData: recordDataName = attributeName + default: continue + } + } else if key == ServiceAttributeNames.keyIsPublic { + if value == "true" { isPublic = true } + } + } + } + + return (isPublic, recordDataName, recordIDName) + } + +} diff --git a/Sources/Extensions/NSManagedObject.swift b/Sources/Extensions/NSManagedObject.swift new file mode 100644 index 00000000..757781ed --- /dev/null +++ b/Sources/Extensions/NSManagedObject.swift @@ -0,0 +1,45 @@ +// +// NSManagedObject.swift +// CloudCore +// +// Created by Vasily Ulianov on 02.02.17. +// Copyright © 2017 Vasily Ulianov. All rights reserved. +// + +import CoreData +import CloudKit + +extension NSManagedObject { + /// Restore record with system fields if that data is saved in recordData attribute (name of attribute is set through user info) + /// + /// - Returns: unacrhived `CKRecord` containing restored system fields (like RecordID, tokens, creationg date etc) + /// - Throws: `CloudCoreError.missingServiceAttributes` if names of CloudCore attributes are not specified in User Info + func restoreRecordWithSystemFields() throws -> CKRecord? { + guard let serviceAttributeNames = self.entity.serviceAttributeNames else { + throw CloudCoreError.missingServiceAttributes(entityName: self.entity.name) + } + guard let encodedRecordData = self.value(forKey: serviceAttributeNames.recordData) as? Data else { return nil } + + return CKRecord(archivedData: encodedRecordData) + } + + + /// Create new CKRecord, write one's encdodedSystemFields and record id to `self` + /// - Postcondition: `self` is modified (recordData and recordID is written) + /// - Throws: may throw exception if unable to find attributes marked by User Info as service attributes + /// - Returns: new `CKRecord` + @discardableResult func setRecordInformation() throws -> CKRecord { + guard let entityName = self.entity.name else { + throw CloudCoreError.coreData("No entity name for \(self.entity)") + } + guard let serviceAttributeNames = self.entity.serviceAttributeNames else { + throw CloudCoreError.missingServiceAttributes(entityName: self.entity.name) + } + + let record = CKRecord(recordType: entityName, zoneID: CloudCore.config.zoneID) + self.setValue(record.encdodedSystemFields, forKey: serviceAttributeNames.recordData) + self.setValue(record.recordID.encodedString, forKey: serviceAttributeNames.recordID) + + return record + } +} diff --git a/Sources/Extensions/NotificationName.swift b/Sources/Extensions/NotificationName.swift new file mode 100644 index 00000000..00f57030 --- /dev/null +++ b/Sources/Extensions/NotificationName.swift @@ -0,0 +1,34 @@ +// +// ActivityIndicatable.swift +// CloudCore +// +// Created by Vasily Ulianov on 06.02.17. +// Copyright © 2017 Vasily Ulianov. All rights reserved. +// + +import Foundation +import CoreData + +// CloudCore custom notifications +public extension Notification.Name { + + /// Posted when CloudCore begins fetching data from iCloud + public static var CloudCoreWillSyncFromCloud: Notification.Name { + return Notification.Name(rawValue: "\(#function)") + } + + /// Posted when CloudCore finished fetching data from iCloud and updated local objects + public static var CloudCoreDidSyncFromCloud: Notification.Name { + return Notification.Name(rawValue: "\(#function)") + } + + /// Posted when CloudCore begins conversion operations (NSManagedObject to CKRecord) and starts uploading to iCloud + public static var CloudCoreWillSyncToCloud: Notification.Name { + return Notification.Name(rawValue: "\(#function)") + } + + /// Posted when CloudCore completed uploading data to iCloud + public static var CloudCoreDidSyncToCloud: Notification.Name { + return Notification.Name(rawValue: "\(#function)") + } +} diff --git a/Sources/Model/CKRecord.swift b/Sources/Model/CKRecord.swift new file mode 100644 index 00000000..5894f9d7 --- /dev/null +++ b/Sources/Model/CKRecord.swift @@ -0,0 +1,27 @@ +// +// CKRecord.swift +// CloudCore +// +// Created by Vasily Ulianov on 02.02.17. +// Copyright © 2017 Vasily Ulianov. All rights reserved. +// + +import CloudKit + +extension CKRecord { + convenience init?(archivedData: Data) { + let unarchiver = NSKeyedUnarchiver(forReadingWith: archivedData) + unarchiver.requiresSecureCoding = true + self.init(coder: unarchiver) + } + + var encdodedSystemFields: Data { + let archivedData = NSMutableData() + let archiver = NSKeyedArchiver(forWritingWith: archivedData) + archiver.requiresSecureCoding = true + self.encodeSystemFields(with: archiver) + archiver.finishEncoding() + + return archivedData as Data + } +} diff --git a/Sources/Model/CloudCoreConfig.swift b/Sources/Model/CloudCoreConfig.swift new file mode 100644 index 00000000..0fb87e92 --- /dev/null +++ b/Sources/Model/CloudCoreConfig.swift @@ -0,0 +1,32 @@ +// +// Scheme.swift +// CloudCore +// +// Created by Vasily Ulianov on 02.02.17. +// Copyright © 2017 Vasily Ulianov. All rights reserved. +// + +import Foundation +import CloudKit + +public struct CloudCoreConfig { + // CloudKit + + /// RecordZone inside private database to store CoreData + public var zoneID = CKRecordZoneID(zoneName: "CloudCore", ownerName: CKCurrentUserDefaultName) + let subscriptionIDForPrivateDB = "CloudCorePrivate" + let subscriptionIDForSharedDB = "CloudCoreShared" + + /// subscriptionID's prefix for custom CKSubscription in public databases + var publicSubscriptionIDPrefix = "CloudCore-" + + + // Core Data + let contextName = "CloudCoreFetchAndSave" + public var defaultAttributeNameRecordID = "recordID" + public var defaultAttributeNameRecordData = "recordData" + + // User Default + public var userDefaultsKeyTokens = "CloudCoreTokens" + public var userDefaultsKeyIsSetuped = "CloudCoreIsSetuped" +} diff --git a/Sources/Model/CloudKitAttribute.swift b/Sources/Model/CloudKitAttribute.swift new file mode 100644 index 00000000..0f936a86 --- /dev/null +++ b/Sources/Model/CloudKitAttribute.swift @@ -0,0 +1,40 @@ +// +// CloudKitAttribute.swift +// CloudCore +// +// Created by Vasily Ulianov on 08.02.17. +// Copyright © 2017 Vasily Ulianov. All rights reserved. +// + +import CloudKit +import CoreData + +class CloudKitAttribute { + let value: Any? + let entityName: String + let serviceAttributes: ServiceAttributeNames + let context: NSManagedObjectContext + + init(value: Any?, entityName: String, serviceAttributes: ServiceAttributeNames, context: NSManagedObjectContext) { + self.value = value + self.entityName = entityName + self.serviceAttributes = serviceAttributes + self.context = context + } + + func makeCoreDataValue() throws -> Any? { + switch value { + case let reference as CKReference: return try findManagedObject(for: reference.recordID) + case let asset as CKAsset: return try Data(contentsOf: asset.fileURL) + default: return value + } + } + + private func findManagedObject(for recordID: CKRecordID) throws -> NSManagedObject? { + let fetchRequest = NSFetchRequest(entityName: entityName) + fetchRequest.predicate = NSPredicate(format: serviceAttributes.recordID + " == %@" , recordID.encodedString) + fetchRequest.fetchLimit = 1 + + return try context.fetch(fetchRequest).first as? NSManagedObject + } +} diff --git a/Sources/Model/ServiceAttributeName.swift b/Sources/Model/ServiceAttributeName.swift new file mode 100644 index 00000000..7174eaf4 --- /dev/null +++ b/Sources/Model/ServiceAttributeName.swift @@ -0,0 +1,23 @@ +// +// ServiceAttributeName.swift +// CloudCore +// +// Created by Vasily Ulianov on 11.02.17. +// Copyright © 2017 Vasily Ulianov. All rights reserved. +// + +import CoreData + +struct ServiceAttributeNames { + // User Info keys & values + static let keyType = "CloudCoreType" + static let keyIsPublic = "CloudCorePublicDatabase" + + static let valueRecordData = "recordData" + static let valueRecordID = "recordID" + + let entityName: String + let recordData: String + let recordID: String + let isPublic: Bool +} diff --git a/Sources/Model/Tokens.swift b/Sources/Model/Tokens.swift new file mode 100644 index 00000000..85fe6ecd --- /dev/null +++ b/Sources/Model/Tokens.swift @@ -0,0 +1,58 @@ +// +// Tokens.swift +// CloudCore +// +// Created by Vasily Ulianov on 07.02.17. +// Copyright © 2017 Vasily Ulianov. All rights reserved. +// + +import CloudKit + +/// CloudKit tokens, recommended to store it locally, class is conforming to `NSCoding` protocol +open class Tokens: NSObject, NSCoding { + open var serverChangeToken: CKServerChangeToken? + open var tokensByRecordZoneID = [CKRecordZoneID: CKServerChangeToken]() + + private struct ArchiverKey { + static let serverToken = "serverChangeToken" + static let tokensByRecordZoneID = "tokensByRecordZoneID" + } + + override init() { + super.init() + } + + public required init?(coder aDecoder: NSCoder) { + self.serverChangeToken = aDecoder.decodeObject(forKey: ArchiverKey.serverToken) as? CKServerChangeToken + self.tokensByRecordZoneID = aDecoder.decodeObject(forKey: ArchiverKey.tokensByRecordZoneID) as? [CKRecordZoneID: CKServerChangeToken] ?? [CKRecordZoneID: CKServerChangeToken]() + } + + open func encode(with aCoder: NSCoder) { + aCoder.encode(serverChangeToken, forKey: ArchiverKey.serverToken) + aCoder.encode(tokensByRecordZoneID, forKey: ArchiverKey.tokensByRecordZoneID) + } + + // MARK: - User Defaults + + /// Load saved Tokens from UserDefaults + /// + /// - Parameter fromKey: UserDefaults key, default is `CloudCore.config.userDefaultsKeyTokens` + /// - Returns: if tokens is not saved before initialize with no tokens + open static func loadFromUserDefaults(fromKey: String = CloudCore.config.userDefaultsKeyTokens) -> Tokens { + guard let tokensData = UserDefaults.standard.data(forKey: fromKey), + let tokens = NSKeyedUnarchiver.unarchiveObject(with: tokensData) as? Tokens else { + return Tokens() + } + + return tokens + } + + /// Save tokens to UserDefaults and synchronize + /// + /// - Parameter forKey: UserDefaults key, default is `CloudCore.config.userDefaultsKeyTokens` + open func saveToUserDefaults(forKey: String = CloudCore.config.userDefaultsKeyTokens) { + let tokensData = NSKeyedArchiver.archivedData(withRootObject: self) + UserDefaults.standard.set(tokensData, forKey: forKey) + UserDefaults.standard.synchronize() + } +} diff --git a/Sources/Protocols/CloudCoreErrorDelegate.swift b/Sources/Protocols/CloudCoreErrorDelegate.swift new file mode 100644 index 00000000..447342ac --- /dev/null +++ b/Sources/Protocols/CloudCoreErrorDelegate.swift @@ -0,0 +1,13 @@ +// +// ErrorReporter.swift +// CloudCore +// +// Created by Vasily Ulianov on 06.02.17. +// Copyright © 2017 Vasily Ulianov. All rights reserved. +// + +import Foundation + +public protocol CloudCoreErrorDelegate { + func cloudCore(saveToCloudDidFailed error: Error) +} diff --git a/Tests/Resources/Info.plist b/Tests/Resources/Info.plist new file mode 100644 index 00000000..6c6c23c4 --- /dev/null +++ b/Tests/Resources/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + BNDL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/Tests/Resources/model.xcdatamodeld/model.xcdatamodel/contents b/Tests/Resources/model.xcdatamodeld/model.xcdatamodel/contents new file mode 100644 index 00000000..3b6591ce --- /dev/null +++ b/Tests/Resources/model.xcdatamodeld/model.xcdatamodel/contents @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Tests/Sources/Classes/ErrorBlockProxyTests.swift b/Tests/Sources/Classes/ErrorBlockProxyTests.swift new file mode 100644 index 00000000..1092c7a7 --- /dev/null +++ b/Tests/Sources/Classes/ErrorBlockProxyTests.swift @@ -0,0 +1,32 @@ +// +// ErrorBlockProxyTests.swift +// CloudCore +// +// Created by Vasily Ulianov on 02.03.17. +// Copyright © 2017 Vasily Ulianov. All rights reserved. +// + +import XCTest + +@testable import CloudCore + +class ErrorBlockProxyTests: XCTestCase { + func testProxy() { + var isErrorReceived = false + let errorBlock: ErrorBlock = { _ in + isErrorReceived = true + } + + let proxy = ErrorBlockProxy(destination: errorBlock) + + // Check null error + proxy.send(error: nil) + XCTAssertFalse(proxy.wasError) + XCTAssertFalse(isErrorReceived) + + // Check that proxy in proxifing + proxy.send(error: CloudCoreError.custom("test")) + XCTAssertTrue(proxy.wasError) + XCTAssertTrue(isErrorReceived) + } +} diff --git a/Tests/Sources/Classes/Fetch/Operations/DeleteFromCoreDataOperationTests.swift b/Tests/Sources/Classes/Fetch/Operations/DeleteFromCoreDataOperationTests.swift new file mode 100644 index 00000000..bb4c2bb7 --- /dev/null +++ b/Tests/Sources/Classes/Fetch/Operations/DeleteFromCoreDataOperationTests.swift @@ -0,0 +1,85 @@ +// +// DeleteFromCoreDataOperationTests.swift +// CloudCore +// +// Created by Vasily Ulianov on 02.03.17. +// Copyright © 2017 Vasily Ulianov. All rights reserved. +// + +import XCTest +import CoreData +import CloudKit + +@testable import CloudCore + +class DeleteFromCoreDataOperationTests: CoreDataTestCase { + + // - MARK: Tests + + func testOperation() { + let remainingObject = TestEntity(context: context) + do { + try remainingObject.setRecordInformation() + + let objectToDelete = TestEntity(context: context) + let record = try objectToDelete.setRecordInformation() + + try context.save() + + let operation = DeleteFromCoreDataOperation(parentContext: context, recordID: record.recordID) + operation.start() + + XCTAssertTrue(objectToDelete.isDeleted) + XCTAssertFalse(remainingObject.isDeleted) + } catch { + XCTFail(error) + } + } + + func testOperationPerfomance() { + // Make dummy objects + let records = self.insertPerfomanceTestObjects() + + measure { + let backgroundContext = self.persistentContainer.newBackgroundContext() + + let queue = OperationQueue() + + for record in records { + let operation = DeleteFromCoreDataOperation(parentContext: backgroundContext, recordID: record.recordID) + queue.addOperation(operation) + } + + queue.waitUntilAllOperationsAreFinished() + } + } + + // - MARK: Helper methods + + /// Prepare for perfomance test (make and insert test objects) + /// + /// - Returns: records for inserted test objects + private func insertPerfomanceTestObjects() -> [CKRecord] { + var recordsToDelete = [CKRecord]() + + for _ in 1...300 { + let objectToDelete = TestEntity(context: context) + do { + let record = try objectToDelete.setRecordInformation() + recordsToDelete.append(record) + } catch { + XCTFail(error) + } + } + + do { + try context.save() + } catch { + XCTFail(error) + } + + return recordsToDelete + } + + +} diff --git a/Tests/Sources/Classes/Fetch/Operations/RecordToCoreDataOperationTests.swift b/Tests/Sources/Classes/Fetch/Operations/RecordToCoreDataOperationTests.swift new file mode 100644 index 00000000..7f0619a1 --- /dev/null +++ b/Tests/Sources/Classes/Fetch/Operations/RecordToCoreDataOperationTests.swift @@ -0,0 +1,70 @@ +// +// RecordToCoreDataOperationTests.swift +// CloudCore +// +// Created by Vasily Ulianov on 02.03.17. +// Copyright © 2017 Vasily Ulianov. All rights reserved. +// + +import XCTest +import CoreData +import CloudKit + +@testable import CloudCore + +class RecordToCoreDataOperationTests: CoreDataTestCase { + + // - MARK: Tests + + func testOperation() { + let (operation, record) = makeConvertOperation(in: self.context) + operation.start() + fetchAndCheck(record: record, in: self.context) + } + + func testOperationsPerformance() { + measure { + let backgroundContext = self.persistentContainer.newBackgroundContext() + let queue = OperationQueue() + + for _ in 1...300 { + let operation = self.makeConvertOperation(in: backgroundContext).operation + queue.addOperation(operation) + } + + queue.waitUntilAllOperationsAreFinished() + } + } + + // MARK: - Helper methods + + /// - Returns: conversion operation and source test `CKRecord` from what that operation was made + func makeConvertOperation(in context: NSManagedObjectContext) -> (operation: RecordToCoreDataOperation, testRecord: CKRecord) { + let record = CorrectObject().makeRecord() + + let convertOperation = RecordToCoreDataOperation(parentContext: context, record: record) + convertOperation.errorBlock = { XCTFail("\($0)") } + + return (convertOperation, record) + } + + /// Find NSManagedObject for specified record and assert if values in that object is equal to record's values + private func fetchAndCheck(record: CKRecord, in context: NSManagedObjectContext) { + context.performAndWait { + // Check operation results + let fetchRequest: NSFetchRequest = TestEntity.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "recordID = %@", record.recordID.encodedString) + do { + guard let managedObject = try context.fetch(fetchRequest).first else { + XCTFail() + return + } + + assertEqualAttributes(managedObject, record) + } catch { + XCTFail("\(error)") + } + } + } + +} diff --git a/Tests/Sources/Classes/Upload/ObjectToRecord/CoreDataAttributeTests.swift b/Tests/Sources/Classes/Upload/ObjectToRecord/CoreDataAttributeTests.swift new file mode 100644 index 00000000..2645553c --- /dev/null +++ b/Tests/Sources/Classes/Upload/ObjectToRecord/CoreDataAttributeTests.swift @@ -0,0 +1,94 @@ +// +// CoreDataAttributeTests.swift +// CloudCore +// +// Created by Vasily Ulianov on 03.03.17. +// Copyright © 2017 Vasily Ulianov. All rights reserved. +// + +import XCTest +import CoreData +import CloudKit + +@testable import CloudCore + +class CoreDataAttributeTests: CoreDataTestCase { + func testInitWithRelationship() { + let incorrectAttribute = CoreDataAttribute(value: "relationship", attributeName: "singleRelationship", entity: TestEntity.entity()) + XCTAssertNil(incorrectAttribute, "Expected nil because it is relationship, not attribute") + } + + func testMakePlainTextAttributes() { + let correctObject = CorrectObject() + let managedObject = correctObject.insert(in: context) + let record = correctObject.makeRecord() + + for (_, attributeDescription) in managedObject.entity.attributesByName { + // Don't check headers, that class is not inteded to convert headers + if ["recordID", "recordData"].contains(attributeDescription.name) { continue } + + let attributeValue = managedObject.value(forKey: attributeDescription.name) + + // Don't test binary here + if attributeValue is NSData { continue } + + let cdAttribute = CoreDataAttribute(value: attributeValue, attributeName: attributeDescription.name, entity: managedObject.entity) + do { + let cdValue = try cdAttribute?.makeRecordValue() + managedObject.setValue(cdValue, forKey: attributeDescription.name) + } catch { + XCTFail(error) + } + } + + assertEqualPlainTextAttributes(managedObject, record) + } + + func testMakeBinaryAttributes() { + let externalData = "data".data(using: .utf8)! + let externalAttribute = CoreDataAttribute(value: externalData, + attributeName: "externalBinary", + entity: TestEntity.entity()) + + let externalBigData = Data.random(length: 1025*1024) + let externalBigAttribute = CoreDataAttribute(value: externalBigData, + attributeName: "binary", + entity: TestEntity.entity()) + + let internalData = "data".data(using: .utf8)! + let internalAttribute = CoreDataAttribute(value: internalData, + attributeName: "binary", + entity: TestEntity.entity()) + + do { + // External binary + if let recordExternalValue = try externalAttribute?.makeRecordValue() as? CKAsset { + let recordData = try Data(contentsOf: recordExternalValue.fileURL) + XCTAssertEqual(recordData, externalData) + } else { + XCTFail("External binary isn't stored correctly") + } + + // External big binary + if let recordExternalValue = try externalBigAttribute?.makeRecordValue() as? CKAsset { + let recordData = try Data(contentsOf: recordExternalValue.fileURL) + XCTAssertEqual(recordData, externalBigData) + } else { + XCTFail("External big binary isn't stored correctly") + } + + // Internal binary + let recordInternalValue = try internalAttribute?.makeRecordValue() as? Data + XCTAssertEqual(recordInternalValue, internalData) + } catch { + XCTFail(error) + } + } +} + +fileprivate extension Data { + static func random(length: Int) -> Data { + let bytes = [UInt32](repeating: 0, count: length).map { _ in arc4random() } + return Data(bytes: bytes, count: length) + } +} diff --git a/Tests/Sources/Classes/Upload/ObjectToRecord/CoreDataRelationshipTests.swift b/Tests/Sources/Classes/Upload/ObjectToRecord/CoreDataRelationshipTests.swift new file mode 100644 index 00000000..358362da --- /dev/null +++ b/Tests/Sources/Classes/Upload/ObjectToRecord/CoreDataRelationshipTests.swift @@ -0,0 +1,72 @@ +// +// CoreDataRelationshipTests.swift +// CloudCore +// +// Created by Vasily Ulianov on 03.03.17. +// Copyright © 2017 Vasily Ulianov. All rights reserved. +// + +import XCTest +import CoreData +import CloudKit + +@testable import CloudCore + +class CoreDataRelationshipTests: CoreDataTestCase { + func testInitWithAttribute() { + let relationship = CoreDataRelationship(value: "attribute", relationshipName: "string", entity: TestEntity.entity()) + XCTAssertNil(relationship, "Expected nil because it is attribute, not relationship") + } + + func testMakeRecordValues() { + // Generate test model + let object = TestEntity(context: context) + try! object.setRecordInformation() + let filledObjectRecord = try! object.restoreRecordWithSystemFields()! + + var manyUsers = [UserEntity]() + var manyUsersRecordsIDs = [CKRecordID]() + for _ in 0...2 { + let user = UserEntity(context: context) + try! user.setRecordInformation() + let userRecord = try! user.restoreRecordWithSystemFields()! + user.recordData = userRecord.encdodedSystemFields as NSData? + + manyUsers.append(user) + manyUsersRecordsIDs.append(userRecord.recordID) + } + + object.singleRelationship = manyUsers[0] + object.manyRelationship = NSSet(array: manyUsers) + + // Fill testable CKRecord + for name in object.entity.relationshipsByName.keys { + let managedObjectValue = object.value(forKey: name)! + guard let relationship = CoreDataRelationship(value: managedObjectValue, relationshipName: name, entity: object.entity) else { + XCTFail("Failed to initialize CoreDataRelationship with attribute: \(name)") + continue + } + + do { + let recordValue = try relationship.makeRecordValue() + filledObjectRecord.setValue(recordValue, forKey: name) + } catch { + XCTFail("Failed to make record value from attribute: \(name), throwed: \(error)") + } + } + + // Check single relationship + let singleReference = filledObjectRecord.value(forKey: "singleRelationship") as! CKReference + XCTAssertEqual(manyUsersRecordsIDs[0], singleReference.recordID) + + // Check many relationships + let multipleReferences = filledObjectRecord.value(forKey: "manyRelationship") as! [CKReference] + var filledRecordRelationshipIDs = [CKRecordID]() + + for recordReference in multipleReferences { + filledRecordRelationshipIDs.append(recordReference.recordID) + } + + XCTAssertEqual(Set(manyUsersRecordsIDs), Set(filledRecordRelationshipIDs)) + } +} diff --git a/Tests/Sources/Classes/Upload/ObjectToRecord/ObjectToRecordOperationTests.swift b/Tests/Sources/Classes/Upload/ObjectToRecord/ObjectToRecordOperationTests.swift new file mode 100644 index 00000000..d68ca432 --- /dev/null +++ b/Tests/Sources/Classes/Upload/ObjectToRecord/ObjectToRecordOperationTests.swift @@ -0,0 +1,109 @@ +// +// ObjectToRecordOperationTests.swift +// CloudCore +// +// Created by Vasily Ulianov on 03.03.17. +// Copyright © 2017 Vasily Ulianov. All rights reserved. +// + +import XCTest +import CoreData +import CloudKit + +@testable import CloudCore + +class ObjectToRecordOperationTests: CoreDataTestCase { + + func createTestObject(in context: NSManagedObjectContext) -> (TestEntity, CKRecord) { + let managedObject = CorrectObject().insert(in: context) + let record = try! managedObject.setRecordInformation() + XCTAssertNil(record.value(forKey: "string")) + + return (managedObject, record) + } + + func testGoodOperation() { + let (managedObject, record) = createTestObject(in: context) + let operation = ObjectToRecordOperation(record: record, changedAttributes: nil, serviceAttributeNames: TestEntity.entity().serviceAttributeNames!) + let conversionExpectation = expectation(description: "ConversionCompleted") + + operation.errorCompletionBlock = { XCTFail($0) } + operation.conversionCompletionBlock = { record in + conversionExpectation.fulfill() + assertEqualAttributes(managedObject, record) + } + operation.parentContext = self.context + operation.start() + + waitForExpectations(timeout: 1, handler: nil) + } + + func testContextIsNotDefined() { + let record = createTestObject(in: context).1 + let operation = ObjectToRecordOperation(record: record, changedAttributes: nil, serviceAttributeNames: TestEntity.entity().serviceAttributeNames!) + let errorExpectation = expectation(description: "ErrorCalled") + + operation.errorCompletionBlock = { error in + if case CloudCoreError.coreData = error { + errorExpectation.fulfill() + } else { + XCTFail("Unexpected error received") + } + } + operation.conversionCompletionBlock = { _ in + XCTFail("Called success completion block while error has been expected") + } + + operation.start() + waitForExpectations(timeout: 1, handler: nil) + } + + func testNoManagedObjectForOperation() { + let record = CorrectObject().makeRecord() + let _ = TestEntity(context: context) + + let operation = ObjectToRecordOperation(record: record, changedAttributes: nil, serviceAttributeNames: TestEntity.entity().serviceAttributeNames!) + operation.parentContext = self.context + let errorExpectation = expectation(description: "ErrorCalled") + + operation.errorCompletionBlock = { error in + if case CloudCoreError.coreData = error { + errorExpectation.fulfill() + } else { + XCTFail("Unexpected error received") + } + } + operation.conversionCompletionBlock = { _ in + XCTFail("Called success completion block while error has been expected") + } + + operation.start() + waitForExpectations(timeout: 1, handler: nil) + } + + func testOperationPerfomance() { + var records = [CKRecord]() + + for _ in 1...300 { + let record = createTestObject(in: context).1 + records.append(record) + } + + try! context.save() + + measure { + let backgroundContext = self.persistentContainer.newBackgroundContext() + let queue = OperationQueue() + + for record in records { + let operation = ObjectToRecordOperation(record: record, changedAttributes: nil, serviceAttributeNames: TestEntity.entity().serviceAttributeNames!) + operation.errorCompletionBlock = { XCTFail($0) } + operation.parentContext = backgroundContext + queue.addOperation(operation) + } + + queue.waitUntilAllOperationsAreFinished() + } + } + +} diff --git a/Tests/Sources/CoreDataTestCase.swift b/Tests/Sources/CoreDataTestCase.swift new file mode 100644 index 00000000..f9f6702c --- /dev/null +++ b/Tests/Sources/CoreDataTestCase.swift @@ -0,0 +1,64 @@ +// +// CoreDataTests.swift +// CloudCore +// +// Created by Vasily Ulianov on 02.02.17. +// Copyright © 2017 Vasily Ulianov. All rights reserved. +// + +import XCTest +import CoreData + +class CoreDataTestCase: XCTestCase { + var context: NSManagedObjectContext { return persistentContainer.viewContext } + private(set) var persistentContainer: NSPersistentContainer! + + private func loadPersistenContainer() -> NSPersistentContainer { + let bundle = Bundle(for: CoreDataTestCase.self) + let url = bundle.url(forResource: "model", withExtension: "momd") + let model = NSManagedObjectModel(contentsOf: url!)! + + let container = NSPersistentContainer(name: "model", managedObjectModel: model) + let description = NSPersistentStoreDescription() + description.type = NSInMemoryStoreType + container.persistentStoreDescriptions = [description] + + container.loadPersistentStores(completionHandler: { (storeDescription, error) in + if let error = error { + fatalError("Unable to load NSPersistentContainer: \(error)") + } + }) + + return container + } + + override func setUp() { + super.setUp() + persistentContainer = loadPersistenContainer() + context.automaticallyMergesChangesFromParent = true + } + + override func tearDown() { + super.tearDown() + persistentContainer = nil + } + + override class func tearDown() { + super.tearDown() + clearTemporaryFolder() + } + + private static func clearTemporaryFolder() { + let fileManager = FileManager.default + let tempFolder = fileManager.temporaryDirectory + + do { + let filePaths = try fileManager.contentsOfDirectory(at: tempFolder, includingPropertiesForKeys: nil, options: []) + for filePath in filePaths { + try fileManager.removeItem(at: filePath) + } + } catch let error as NSError { + XCTFail("Could not clear temp folder: \(error.debugDescription)") + } + } +} diff --git a/Tests/Sources/CorrectObject.swift b/Tests/Sources/CorrectObject.swift new file mode 100644 index 00000000..c9909ee0 --- /dev/null +++ b/Tests/Sources/CorrectObject.swift @@ -0,0 +1,138 @@ +// +// CorrectManagedObjectRecord.swift +// CloudCore +// +// Created by Vasily Ulianov on 02.02.17. +// Copyright © 2017 Vasily Ulianov. All rights reserved. +// + +import XCTest +import CoreData +import CloudKit + +@testable import CloudCore + +struct CorrectObject { + let recordData: Data = CKRecord(recordType: "TestEntity").encdodedSystemFields + let binary = "binary data".data(using: .utf8)! + let externalBinary = "external binary data".data(using: .utf8)! + + let string = "text" + + let int16 = Int16.max + let int32 = Int32.max + let int64 = Int64.max + let decimal = NSDecimalNumber.maximum + let double = Double.greatestFiniteMagnitude + let float = Float.greatestFiniteMagnitude + + let date = NSDate() + let bool = true + + func insert(in context: NSManagedObjectContext) -> TestEntity { + let managedObject = TestEntity(context: context) + + // Header + managedObject.recordData = self.recordData as NSData + + // Binary + managedObject.binary = binary as NSData + managedObject.externalBinary = externalBinary as NSData + managedObject.transformable = NSData() + + // Plain-text + managedObject.string = self.string + + managedObject.int16 = self.int16 + managedObject.int32 = self.int32 + managedObject.int64 = self.int64 + managedObject.decimal = self.decimal + managedObject.double = self.double + managedObject.float = self.float + + managedObject.date = self.date + managedObject.bool = self.bool + +// // Relationships +// let user1 = UserEntity(context: context) +// let user2 = UserEntity(context: context) +// managedObject.singleRelationship = user1 +// managedObject.manyRelationship = NSSet(array: [user1, user2]) + + return managedObject + } + + func makeRecord() -> CKRecord { + let record = CKRecord(recordType: "TestEntity", zoneID: CloudCore.config.zoneID) + + let asset = try? CoreDataAttribute.createAsset(for: externalBinary) + XCTAssertNotNil(asset) + record.setValue(asset, forKey: "externalBinary") + + record.setValue(self.binary, forKey: "binary") + + record.setValue(self.string, forKey: "string") + record.setValue(self.int16, forKey: "int16") + record.setValue(self.int32, forKey: "int32") + record.setValue(self.int64, forKey: "int64") + record.setValue(self.decimal, forKey: "decimal") + record.setValue(self.double, forKey: "double") + record.setValue(self.float, forKey: "float") + record.setValue(self.date, forKey: "date") + record.setValue(self.bool, forKey: "bool") + + return record + } +} + +func assertEqualAttributes(_ managedObject: TestEntity, _ record: CKRecord) { + // Headers + if let encodedRecordData = managedObject.recordData as Data? { + let recordFromObject = CKRecord(archivedData: encodedRecordData) + + XCTAssertEqual(recordFromObject?.recordID, record.recordID) + } + + assertEqualPlainTextAttributes(managedObject, record) + assertEqualBinaryAttributes(managedObject, record) +} + +func assertEqualPlainTextAttributes(_ managedObject: TestEntity, _ record: CKRecord) { + XCTAssertEqual(managedObject.string, record.value(forKey: "string") as! String?) + + let recordInt16 = (record.value(forKey: "int16") as! NSNumber?)?.int16Value ?? 0 + XCTAssertEqual(managedObject.int16, recordInt16) + + let recordInt32 = (record.value(forKey: "int32") as! NSNumber?)?.int32Value ?? 0 + XCTAssertEqual(managedObject.int32, recordInt32) + + let recordInt64 = (record.value(forKey: "int64") as! NSNumber?)?.int64Value ?? 0 + XCTAssertEqual(managedObject.int64, recordInt64) + + let recordDecimal = (record.value(forKey: "decimal") as! NSNumber?)?.decimalValue ?? 0 + XCTAssertEqual(managedObject.decimal as Decimal?, recordDecimal) + + let recordDouble = (record.value(forKey: "double") as! NSNumber?)?.doubleValue ?? 0 + XCTAssertEqual(managedObject.double, recordDouble) + + let recordFloat = (record.value(forKey: "float") as! NSNumber?)?.floatValue ?? 0 + XCTAssertEqual(managedObject.float, recordFloat) + + let recordDate = (record.value(forKey: "date") as! NSDate?)?.timeIntervalSinceReferenceDate + XCTAssertEqual(managedObject.date?.timeIntervalSinceReferenceDate, recordDate) + + let recordBool = record.value(forKey: "bool") as! Bool? ?? false + XCTAssertEqual(managedObject.bool, recordBool) + + XCTAssertEqual(nil, record.value(forKey: "empty") as! String?) + XCTAssertEqual(managedObject.empty, nil) +} + +func assertEqualBinaryAttributes(_ managedObject: TestEntity, _ record: CKRecord) { + if let recordAsset = record.value(forKey: "externalBinary") as! CKAsset? { + let downloadedData = NSData(contentsOf: recordAsset.fileURL) + XCTAssertEqual(managedObject.externalBinary, downloadedData) + } + + XCTAssertEqual(managedObject.binary, record.value(forKey: "binary") as! NSData?) +} diff --git a/Tests/Sources/CustomFunctions.swift b/Tests/Sources/CustomFunctions.swift new file mode 100644 index 00000000..30937eb4 --- /dev/null +++ b/Tests/Sources/CustomFunctions.swift @@ -0,0 +1,19 @@ +// +// XCTAssertThrowsSpecific.swift +// CloudCore +// +// Created by Vasily Ulianov on 02.03.17. +// Copyright © 2017 Vasily Ulianov. All rights reserved. +// + +import XCTest + +func XCTAssertThrowsSpecific(_ expression: @autoclosure () throws -> T, _ error: Error) { + XCTAssertThrowsError(expression) { (throwedError) in + XCTAssertEqual("\(throwedError)", "\(error)", "XCTAssertThrowsSpecific: errors are not equal") + } +} + +func XCTFail(_ error: Error) { + XCTFail("\(error)") +} diff --git a/Tests/Sources/Extensions/CKRecordIDTests.swift b/Tests/Sources/Extensions/CKRecordIDTests.swift new file mode 100644 index 00000000..7ca18910 --- /dev/null +++ b/Tests/Sources/Extensions/CKRecordIDTests.swift @@ -0,0 +1,26 @@ +// +// CKRecordID.swift +// CloudCore +// +// Created by Vasily Ulianov on 01.03.17. +// Copyright © 2017 Vasily Ulianov. All rights reserved. +// + +import XCTest +import CloudKit + +@testable import CloudCore + +class CKRecordIDTests: XCTestCase { + func testRecordIDEncodeDecode() { + let zoneID = CKRecordZoneID(zoneName: CloudCore.config.zoneID.zoneName, ownerName: CKCurrentUserDefaultName) + let recordID = CKRecordID(recordName: "testName", zoneID: zoneID) + + let encodedString = recordID.encodedString + let restoredRecordID = CKRecordID(encodedString: encodedString) + + XCTAssertEqual(recordID.recordName, restoredRecordID?.recordName) + XCTAssertEqual(recordID.zoneID, restoredRecordID?.zoneID) + + } +} diff --git a/Tests/Sources/Extensions/NSEntityDescriptionTests.swift b/Tests/Sources/Extensions/NSEntityDescriptionTests.swift new file mode 100644 index 00000000..7264cc01 --- /dev/null +++ b/Tests/Sources/Extensions/NSEntityDescriptionTests.swift @@ -0,0 +1,26 @@ +// +// NSEntityDescription.swift +// CloudCore +// +// Created by Vasily Ulianov on 01.03.17. +// Copyright © 2017 Vasily Ulianov. All rights reserved. +// + +import XCTest +import CoreData + +@testable import CloudCore + +class NSEntityDescriptionTests: CoreDataTestCase { + func testServiceAttributeNames() { + let correctObject = TestEntity(context: self.context) + + let attributeNames = correctObject.entity.serviceAttributeNames + XCTAssertEqual(attributeNames?.entityName, "TestEntity") + XCTAssertEqual(attributeNames?.recordData, "recordData") + XCTAssertEqual(attributeNames?.recordID, "recordID") + + let incorrectObject = IncorrectEntity(context: self.context) + XCTAssertNil(incorrectObject.entity.serviceAttributeNames) + } +} diff --git a/Tests/Sources/Extensions/NSManagedObjectTests.swift b/Tests/Sources/Extensions/NSManagedObjectTests.swift new file mode 100644 index 00000000..e972c018 --- /dev/null +++ b/Tests/Sources/Extensions/NSManagedObjectTests.swift @@ -0,0 +1,53 @@ +// +// NSManagedObjectTests.swift +// CloudCore +// +// Created by Vasily Ulianov on 04.02.17. +// Copyright © 2017 Vasily Ulianov. All rights reserved. +// + +import XCTest +import CoreData +import CloudKit + +@testable import CloudCore + +class NSManagedObjectTests: CoreDataTestCase { + func testRestoreRecordWithSystemFields() { + let object = TestEntity(context: context) + do { + try object.setRecordInformation() + + let record = try object.restoreRecordWithSystemFields() + XCTAssertEqual(record?.recordType, "TestEntity") + XCTAssertEqual(record?.recordID.zoneID, CloudCore.config.zoneID) + } catch { + XCTFail("\(error)") + } + } + + /// If no record data is saved + func testRestoreObjectWithoutData() { + let object = TestEntity(context: context) + do { + let record = try object.restoreRecordWithSystemFields() + XCTAssertNil(record) + } catch { + XCTFail("\(error)") + } + } + + // MARK: - Expected throws + + func testSetRecordInformationThrow() { + let object = IncorrectEntity(context: context) + + XCTAssertThrowsSpecific(try object.setRecordInformation(), CloudCoreError.missingServiceAttributes(entityName: "IncorrectEntity")) + } + + func testRestoreRecordThrow() { + let object = IncorrectEntity(context: context) + + XCTAssertThrowsSpecific(try object.restoreRecordWithSystemFields(), CloudCoreError.missingServiceAttributes(entityName: "IncorrectEntity")) + } +} diff --git a/Tests/Sources/Model/CKRecordTests.swift b/Tests/Sources/Model/CKRecordTests.swift new file mode 100644 index 00000000..cacaea0d --- /dev/null +++ b/Tests/Sources/Model/CKRecordTests.swift @@ -0,0 +1,29 @@ +// +// CKRecord.swift +// CloudCore +// +// Created by Vasily Ulianov on 01.03.17. +// Copyright © 2017 Vasily Ulianov. All rights reserved. +// + +import XCTest +import CloudKit + +@testable import CloudCore + +class CKRecordTests: XCTestCase { + func testEncodeAndInit() { + let zoneID = CKRecordZoneID(zoneName: "zone", ownerName: CKCurrentUserDefaultName) + let record = CKRecord(recordType: "type", zoneID: zoneID) + record.setValue("testValue", forKey: "testKey") + + let encodedData = record.encdodedSystemFields + guard let restoredRecord = CKRecord(archivedData: encodedData) else { + XCTFail("Failed to restore record from archivedData") + return + } + + XCTAssertEqual(restoredRecord.recordID, record.recordID) + XCTAssertNil(restoredRecord.value(forKey: "testKey")) + } +}