├── .github ├── FUNDING.yml └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── .gitignore ├── CHANGELOG.md ├── ContactsChangeNotifier.podspec ├── Example ├── ContactsChangeNotifierDemo.xcodeproj │ ├── project.pbxproj │ └── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist ├── ContactsChangeNotifierDemo.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist ├── ContactsChangeNotifierDemo │ ├── Assets.xcassets │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ │ └── Contents.json │ │ └── Contents.json │ ├── Contacts+Extensions.swift │ ├── ContactsChangeNotifierDemoApp.swift │ ├── ContentView.swift │ └── Preview Content │ │ └── Preview Assets.xcassets │ │ └── Contents.json ├── ContactsChangeNotifierTests │ └── ContactsChangeNotifierTests.swift ├── Podfile └── Podfile.lock ├── LICENSE.txt ├── Package.swift ├── README.md ├── Screenshots └── ContactsChanges.png └── Sources ├── ContactStoreChangeHistory ├── CNContactStore+ChangeHistory.h ├── CNContactStore+ChangeHistory.m └── include │ └── ContactStoreChangeHistory.h └── ContactsChangeNotifier ├── ContactsChangeNotifier.swift ├── HistoryTokenStorage ├── CloudKitHistoryTokenStorage.swift ├── HistoryTokenStorage.swift └── UserDefaultsHistoryTokenStorage.swift └── PrivacyInfo.xcprivacy /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [yonat] 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: yonat 7 | 8 | --- 9 | 10 | **Description of the problem:** 11 | [description] 12 | 13 | **Minimal project that reproduces the problem (so I'll be able to figure out how to fix it):** 14 | [link to a Minimal Reproducible Example as described at https://ootips.org/yonat/repex ] 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: yonat 7 | 8 | --- 9 | 10 | **Description:** 11 | [description] 12 | 13 | **Problems I encountered when trying to implement this myself:** 14 | [if none, please submit a pull request.] 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | Example/Pods/ 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 5 | and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 6 | 7 | ## [1.2.0] - 2024-10-20 8 | 9 | ### Added 10 | - Allow custom storage for `lastHistoryToken`, in addition to iCloud and UserDefaults. (thanks JPToroDev!) 11 | - Support Swift 6 concurrency. (thanks JPToroDev!) 12 | 13 | ## [1.1.0] - 2024-09-28 14 | 15 | ### Added 16 | - add option to save the `lastHistoryToken` in iCloud instead of locally. (thanks JPToroDev!) 17 | 18 | ### Fixed 19 | - fix PrivacyInfo.xcprivacy for spm 20 | 21 | ## [1.0.5] - 2023-08-19 22 | 23 | ### Added 24 | - add privacy manifest PrivacyInfo.xcprivacy. 25 | 26 | ## [1.0.4] - 2023-08-12 27 | 28 | ### Fixed 29 | - Fix test and raise minimum deployment target to iOS 15. 30 | 31 | ## [1.0.3] - 2022-11-08 32 | 33 | ### Fixed 34 | - fix crash after second app open (1.0.2 regression). 35 | - get changes on app/notifier start (e.g. if app was force-quit). 36 | 37 | ## [1.0.2] - 2022-09-29 38 | 39 | ### Fixed 40 | - allow to include in code used by app extensions (fix #1). 41 | 42 | ## [1.0.1] - 2022-07-17 43 | 44 | ### Fixed 45 | - ensure UIApplication.applicationState is used from the main thread. 46 | - skip internal changes when later getting external changes. 47 | - get contacts history in background thread. 48 | 49 | ## [1.0.0] - 2022-07-14 50 | 51 | Initial release. 52 | -------------------------------------------------------------------------------- /ContactsChangeNotifier.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |s| 2 | 3 | s.name = 'ContactsChangeNotifier' 4 | s.version = '1.2.0' 5 | s.summary = 'Which contacts changed outside your iOS app? Better CNContactStoreDidChange notification: get real changes, without the noise.' 6 | s.homepage = 'https://github.com/yonat/ContactsChangeNotifier' 7 | s.license = { :type => 'MIT', :file => 'LICENSE.txt' } 8 | 9 | s.author = { 'Yonat Sharon' => 'yonat@ootips.org' } 10 | 11 | s.platform = :ios, '15.0' 12 | s.swift_versions = ['5.0'] 13 | 14 | s.source = { :git => 'https://github.com/yonat/ContactsChangeNotifier.git', :tag => s.version } 15 | s.source_files = 'Sources/ContactStoreChangeHistory/*.{h,m}', 'Sources/ContactsChangeNotifier/**/*.swift' 16 | s.resource_bundles = {s.name => ['Sources/ContactsChangeNotifier/PrivacyInfo.xcprivacy']} 17 | 18 | end 19 | -------------------------------------------------------------------------------- /Example/ContactsChangeNotifierDemo.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 55; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 40500EF188A13F3968AE0448 /* Pods_ContactsChangeNotifierDemo.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 015B6DD0A0F8B23420F7F860 /* Pods_ContactsChangeNotifierDemo.framework */; }; 11 | DC35A32C28801D6E0056FBFD /* Contacts+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC35A32B28801D6E0056FBFD /* Contacts+Extensions.swift */; }; 12 | DCC676E128800019007156EA /* ContactsChangeNotifierDemoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCC676E028800019007156EA /* ContactsChangeNotifierDemoApp.swift */; }; 13 | DCC676E328800019007156EA /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCC676E228800019007156EA /* ContentView.swift */; }; 14 | DCC676E52880001C007156EA /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DCC676E42880001C007156EA /* Assets.xcassets */; }; 15 | DCC676E82880001C007156EA /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DCC676E72880001C007156EA /* Preview Assets.xcassets */; }; 16 | DCF938DF288136FB006D855C /* ContactsChangeNotifierTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCF938DE288136FB006D855C /* ContactsChangeNotifierTests.swift */; }; 17 | FBC12872363B7832D9714C44 /* Pods_ContactsChangeNotifierTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6B3F14A452C43A1CE3D0FB90 /* Pods_ContactsChangeNotifierTests.framework */; }; 18 | /* End PBXBuildFile section */ 19 | 20 | /* Begin PBXContainerItemProxy section */ 21 | DCF938E0288136FB006D855C /* PBXContainerItemProxy */ = { 22 | isa = PBXContainerItemProxy; 23 | containerPortal = DCC676D528800019007156EA /* Project object */; 24 | proxyType = 1; 25 | remoteGlobalIDString = DCC676DC28800019007156EA; 26 | remoteInfo = ContactsChangeNotifierDemo; 27 | }; 28 | /* End PBXContainerItemProxy section */ 29 | 30 | /* Begin PBXFileReference section */ 31 | 015B6DD0A0F8B23420F7F860 /* Pods_ContactsChangeNotifierDemo.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_ContactsChangeNotifierDemo.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 32 | 3E5E29F603D6F987FCD6B29C /* Pods-ContactsChangeNotifierTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ContactsChangeNotifierTests.release.xcconfig"; path = "Target Support Files/Pods-ContactsChangeNotifierTests/Pods-ContactsChangeNotifierTests.release.xcconfig"; sourceTree = ""; }; 33 | 4E95F5D17DA305F21E07813B /* Pods-ContactsChangeNotifierDemo.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ContactsChangeNotifierDemo.debug.xcconfig"; path = "Target Support Files/Pods-ContactsChangeNotifierDemo/Pods-ContactsChangeNotifierDemo.debug.xcconfig"; sourceTree = ""; }; 34 | 6B3F14A452C43A1CE3D0FB90 /* Pods_ContactsChangeNotifierTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_ContactsChangeNotifierTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 35 | 758511CED2F346FF2D97EDAC /* Pods-ContactsChangeNotifierTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ContactsChangeNotifierTests.debug.xcconfig"; path = "Target Support Files/Pods-ContactsChangeNotifierTests/Pods-ContactsChangeNotifierTests.debug.xcconfig"; sourceTree = ""; }; 36 | C832BAF0520B3CF98E12A9D4 /* Pods-ContactsChangeNotifierDemo.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ContactsChangeNotifierDemo.release.xcconfig"; path = "Target Support Files/Pods-ContactsChangeNotifierDemo/Pods-ContactsChangeNotifierDemo.release.xcconfig"; sourceTree = ""; }; 37 | DC35A32B28801D6E0056FBFD /* Contacts+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Contacts+Extensions.swift"; sourceTree = ""; }; 38 | DCC676DD28800019007156EA /* ContactsChangeNotifierDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ContactsChangeNotifierDemo.app; sourceTree = BUILT_PRODUCTS_DIR; }; 39 | DCC676E028800019007156EA /* ContactsChangeNotifierDemoApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactsChangeNotifierDemoApp.swift; sourceTree = ""; }; 40 | DCC676E228800019007156EA /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 41 | DCC676E42880001C007156EA /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 42 | DCC676E72880001C007156EA /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 43 | DCF938DC288136FB006D855C /* ContactsChangeNotifierTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ContactsChangeNotifierTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 44 | DCF938DE288136FB006D855C /* ContactsChangeNotifierTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactsChangeNotifierTests.swift; sourceTree = ""; }; 45 | /* End PBXFileReference section */ 46 | 47 | /* Begin PBXFrameworksBuildPhase section */ 48 | DCC676DA28800019007156EA /* Frameworks */ = { 49 | isa = PBXFrameworksBuildPhase; 50 | buildActionMask = 2147483647; 51 | files = ( 52 | 40500EF188A13F3968AE0448 /* Pods_ContactsChangeNotifierDemo.framework in Frameworks */, 53 | ); 54 | runOnlyForDeploymentPostprocessing = 0; 55 | }; 56 | DCF938D9288136FB006D855C /* Frameworks */ = { 57 | isa = PBXFrameworksBuildPhase; 58 | buildActionMask = 2147483647; 59 | files = ( 60 | FBC12872363B7832D9714C44 /* Pods_ContactsChangeNotifierTests.framework in Frameworks */, 61 | ); 62 | runOnlyForDeploymentPostprocessing = 0; 63 | }; 64 | /* End PBXFrameworksBuildPhase section */ 65 | 66 | /* Begin PBXGroup section */ 67 | 21904AC1573484E32ECADAC4 /* Frameworks */ = { 68 | isa = PBXGroup; 69 | children = ( 70 | 015B6DD0A0F8B23420F7F860 /* Pods_ContactsChangeNotifierDemo.framework */, 71 | 6B3F14A452C43A1CE3D0FB90 /* Pods_ContactsChangeNotifierTests.framework */, 72 | ); 73 | name = Frameworks; 74 | sourceTree = ""; 75 | }; 76 | 9017D2988484696C88D0113D /* Pods */ = { 77 | isa = PBXGroup; 78 | children = ( 79 | 4E95F5D17DA305F21E07813B /* Pods-ContactsChangeNotifierDemo.debug.xcconfig */, 80 | C832BAF0520B3CF98E12A9D4 /* Pods-ContactsChangeNotifierDemo.release.xcconfig */, 81 | 758511CED2F346FF2D97EDAC /* Pods-ContactsChangeNotifierTests.debug.xcconfig */, 82 | 3E5E29F603D6F987FCD6B29C /* Pods-ContactsChangeNotifierTests.release.xcconfig */, 83 | ); 84 | path = Pods; 85 | sourceTree = ""; 86 | }; 87 | DCC676D428800019007156EA = { 88 | isa = PBXGroup; 89 | children = ( 90 | DCC676DF28800019007156EA /* ContactsChangeNotifierDemo */, 91 | DCF938DD288136FB006D855C /* ContactsChangeNotifierTests */, 92 | DCC676DE28800019007156EA /* Products */, 93 | 9017D2988484696C88D0113D /* Pods */, 94 | 21904AC1573484E32ECADAC4 /* Frameworks */, 95 | ); 96 | sourceTree = ""; 97 | }; 98 | DCC676DE28800019007156EA /* Products */ = { 99 | isa = PBXGroup; 100 | children = ( 101 | DCC676DD28800019007156EA /* ContactsChangeNotifierDemo.app */, 102 | DCF938DC288136FB006D855C /* ContactsChangeNotifierTests.xctest */, 103 | ); 104 | name = Products; 105 | sourceTree = ""; 106 | }; 107 | DCC676DF28800019007156EA /* ContactsChangeNotifierDemo */ = { 108 | isa = PBXGroup; 109 | children = ( 110 | DCC676E028800019007156EA /* ContactsChangeNotifierDemoApp.swift */, 111 | DCC676E228800019007156EA /* ContentView.swift */, 112 | DC35A32B28801D6E0056FBFD /* Contacts+Extensions.swift */, 113 | DCC676E42880001C007156EA /* Assets.xcassets */, 114 | DCC676E62880001C007156EA /* Preview Content */, 115 | ); 116 | path = ContactsChangeNotifierDemo; 117 | sourceTree = ""; 118 | }; 119 | DCC676E62880001C007156EA /* Preview Content */ = { 120 | isa = PBXGroup; 121 | children = ( 122 | DCC676E72880001C007156EA /* Preview Assets.xcassets */, 123 | ); 124 | path = "Preview Content"; 125 | sourceTree = ""; 126 | }; 127 | DCF938DD288136FB006D855C /* ContactsChangeNotifierTests */ = { 128 | isa = PBXGroup; 129 | children = ( 130 | DCF938DE288136FB006D855C /* ContactsChangeNotifierTests.swift */, 131 | ); 132 | path = ContactsChangeNotifierTests; 133 | sourceTree = ""; 134 | }; 135 | /* End PBXGroup section */ 136 | 137 | /* Begin PBXNativeTarget section */ 138 | DCC676DC28800019007156EA /* ContactsChangeNotifierDemo */ = { 139 | isa = PBXNativeTarget; 140 | buildConfigurationList = DCC676EB2880001C007156EA /* Build configuration list for PBXNativeTarget "ContactsChangeNotifierDemo" */; 141 | buildPhases = ( 142 | 93ABF910618E93A5F5351809 /* [CP] Check Pods Manifest.lock */, 143 | C5EC6C9FAA7F27D913B3D44E /* [CP-User] SwiftFormat */, 144 | 33D012AFD24C971C4DD3511C /* [CP-User] SwiftLintAutocorrect */, 145 | DCC676D928800019007156EA /* Sources */, 146 | DCC676DA28800019007156EA /* Frameworks */, 147 | DCC676DB28800019007156EA /* Resources */, 148 | D640452F080E86AB934F2E71 /* [CP] Embed Pods Frameworks */, 149 | D6D8FC803B30933F54E11662 /* [CP-User] SwiftLint */, 150 | ); 151 | buildRules = ( 152 | ); 153 | dependencies = ( 154 | ); 155 | name = ContactsChangeNotifierDemo; 156 | productName = ContactsChangeNotifierDemo; 157 | productReference = DCC676DD28800019007156EA /* ContactsChangeNotifierDemo.app */; 158 | productType = "com.apple.product-type.application"; 159 | }; 160 | DCF938DB288136FB006D855C /* ContactsChangeNotifierTests */ = { 161 | isa = PBXNativeTarget; 162 | buildConfigurationList = DCF938E4288136FB006D855C /* Build configuration list for PBXNativeTarget "ContactsChangeNotifierTests" */; 163 | buildPhases = ( 164 | 8507292E799FE9ABE8CFEC51 /* [CP] Check Pods Manifest.lock */, 165 | DCF938D8288136FB006D855C /* Sources */, 166 | DCF938D9288136FB006D855C /* Frameworks */, 167 | DCF938DA288136FB006D855C /* Resources */, 168 | 9DDCC9F3C56E7DE6150045FB /* [CP] Embed Pods Frameworks */, 169 | ); 170 | buildRules = ( 171 | ); 172 | dependencies = ( 173 | DCF938E1288136FB006D855C /* PBXTargetDependency */, 174 | ); 175 | name = ContactsChangeNotifierTests; 176 | productName = ContactsChangeNotifierTests; 177 | productReference = DCF938DC288136FB006D855C /* ContactsChangeNotifierTests.xctest */; 178 | productType = "com.apple.product-type.bundle.unit-test"; 179 | }; 180 | /* End PBXNativeTarget section */ 181 | 182 | /* Begin PBXProject section */ 183 | DCC676D528800019007156EA /* Project object */ = { 184 | isa = PBXProject; 185 | attributes = { 186 | BuildIndependentTargetsInParallel = 1; 187 | LastSwiftUpdateCheck = 1340; 188 | LastUpgradeCheck = 1400; 189 | TargetAttributes = { 190 | DCC676DC28800019007156EA = { 191 | CreatedOnToolsVersion = 14.0; 192 | }; 193 | DCF938DB288136FB006D855C = { 194 | CreatedOnToolsVersion = 13.4; 195 | TestTargetID = DCC676DC28800019007156EA; 196 | }; 197 | }; 198 | }; 199 | buildConfigurationList = DCC676D828800019007156EA /* Build configuration list for PBXProject "ContactsChangeNotifierDemo" */; 200 | compatibilityVersion = "Xcode 13.0"; 201 | developmentRegion = en; 202 | hasScannedForEncodings = 0; 203 | knownRegions = ( 204 | en, 205 | Base, 206 | ); 207 | mainGroup = DCC676D428800019007156EA; 208 | productRefGroup = DCC676DE28800019007156EA /* Products */; 209 | projectDirPath = ""; 210 | projectRoot = ""; 211 | targets = ( 212 | DCC676DC28800019007156EA /* ContactsChangeNotifierDemo */, 213 | DCF938DB288136FB006D855C /* ContactsChangeNotifierTests */, 214 | ); 215 | }; 216 | /* End PBXProject section */ 217 | 218 | /* Begin PBXResourcesBuildPhase section */ 219 | DCC676DB28800019007156EA /* Resources */ = { 220 | isa = PBXResourcesBuildPhase; 221 | buildActionMask = 2147483647; 222 | files = ( 223 | DCC676E82880001C007156EA /* Preview Assets.xcassets in Resources */, 224 | DCC676E52880001C007156EA /* Assets.xcassets in Resources */, 225 | ); 226 | runOnlyForDeploymentPostprocessing = 0; 227 | }; 228 | DCF938DA288136FB006D855C /* Resources */ = { 229 | isa = PBXResourcesBuildPhase; 230 | buildActionMask = 2147483647; 231 | files = ( 232 | ); 233 | runOnlyForDeploymentPostprocessing = 0; 234 | }; 235 | /* End PBXResourcesBuildPhase section */ 236 | 237 | /* Begin PBXShellScriptBuildPhase section */ 238 | 33D012AFD24C971C4DD3511C /* [CP-User] SwiftLintAutocorrect */ = { 239 | isa = PBXShellScriptBuildPhase; 240 | alwaysOutOfDate = 1; 241 | buildActionMask = 2147483647; 242 | files = ( 243 | ); 244 | name = "[CP-User] SwiftLintAutocorrect"; 245 | runOnlyForDeploymentPostprocessing = 0; 246 | shellPath = /bin/sh; 247 | shellScript = "if [[ \"Debug\" == \"${CONFIGURATION}\" && ! $ENABLE_PREVIEWS == \"YES\" ]]; then \"${PODS_ROOT}/SwiftLint/swiftlint\" --fix --config \"${PODS_ROOT}/SwiftQuality/.swiftlint.yml\" \"${SRCROOT}/..\" ; fi"; 248 | }; 249 | 8507292E799FE9ABE8CFEC51 /* [CP] Check Pods Manifest.lock */ = { 250 | isa = PBXShellScriptBuildPhase; 251 | buildActionMask = 2147483647; 252 | files = ( 253 | ); 254 | inputFileListPaths = ( 255 | ); 256 | inputPaths = ( 257 | "${PODS_PODFILE_DIR_PATH}/Podfile.lock", 258 | "${PODS_ROOT}/Manifest.lock", 259 | ); 260 | name = "[CP] Check Pods Manifest.lock"; 261 | outputFileListPaths = ( 262 | ); 263 | outputPaths = ( 264 | "$(DERIVED_FILE_DIR)/Pods-ContactsChangeNotifierTests-checkManifestLockResult.txt", 265 | ); 266 | runOnlyForDeploymentPostprocessing = 0; 267 | shellPath = /bin/sh; 268 | shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; 269 | showEnvVarsInLog = 0; 270 | }; 271 | 93ABF910618E93A5F5351809 /* [CP] Check Pods Manifest.lock */ = { 272 | isa = PBXShellScriptBuildPhase; 273 | buildActionMask = 2147483647; 274 | files = ( 275 | ); 276 | inputFileListPaths = ( 277 | ); 278 | inputPaths = ( 279 | "${PODS_PODFILE_DIR_PATH}/Podfile.lock", 280 | "${PODS_ROOT}/Manifest.lock", 281 | ); 282 | name = "[CP] Check Pods Manifest.lock"; 283 | outputFileListPaths = ( 284 | ); 285 | outputPaths = ( 286 | "$(DERIVED_FILE_DIR)/Pods-ContactsChangeNotifierDemo-checkManifestLockResult.txt", 287 | ); 288 | runOnlyForDeploymentPostprocessing = 0; 289 | shellPath = /bin/sh; 290 | shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; 291 | showEnvVarsInLog = 0; 292 | }; 293 | 9DDCC9F3C56E7DE6150045FB /* [CP] Embed Pods Frameworks */ = { 294 | isa = PBXShellScriptBuildPhase; 295 | alwaysOutOfDate = 1; 296 | buildActionMask = 2147483647; 297 | files = ( 298 | ); 299 | inputFileListPaths = ( 300 | "${PODS_ROOT}/Target Support Files/Pods-ContactsChangeNotifierTests/Pods-ContactsChangeNotifierTests-frameworks-${CONFIGURATION}-input-files.xcfilelist", 301 | ); 302 | name = "[CP] Embed Pods Frameworks"; 303 | outputFileListPaths = ( 304 | "${PODS_ROOT}/Target Support Files/Pods-ContactsChangeNotifierTests/Pods-ContactsChangeNotifierTests-frameworks-${CONFIGURATION}-output-files.xcfilelist", 305 | ); 306 | runOnlyForDeploymentPostprocessing = 0; 307 | shellPath = /bin/sh; 308 | shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-ContactsChangeNotifierTests/Pods-ContactsChangeNotifierTests-frameworks.sh\"\n"; 309 | showEnvVarsInLog = 0; 310 | }; 311 | C5EC6C9FAA7F27D913B3D44E /* [CP-User] SwiftFormat */ = { 312 | isa = PBXShellScriptBuildPhase; 313 | alwaysOutOfDate = 1; 314 | buildActionMask = 2147483647; 315 | files = ( 316 | ); 317 | name = "[CP-User] SwiftFormat"; 318 | runOnlyForDeploymentPostprocessing = 0; 319 | shellPath = /bin/sh; 320 | shellScript = "if [[ \"Debug\" == \"${CONFIGURATION}\" && ! $ENABLE_PREVIEWS == \"YES\" ]]; then \"${PODS_ROOT}/SwiftFormat/CommandLineTool/swiftformat\" --swiftversion ${SWIFT_VERSION} --config \"${PODS_ROOT}/SwiftQuality/.swiftformat\" \"${SRCROOT}/..\" ; fi"; 321 | }; 322 | D640452F080E86AB934F2E71 /* [CP] Embed Pods Frameworks */ = { 323 | isa = PBXShellScriptBuildPhase; 324 | alwaysOutOfDate = 1; 325 | buildActionMask = 2147483647; 326 | files = ( 327 | ); 328 | inputFileListPaths = ( 329 | "${PODS_ROOT}/Target Support Files/Pods-ContactsChangeNotifierDemo/Pods-ContactsChangeNotifierDemo-frameworks-${CONFIGURATION}-input-files.xcfilelist", 330 | ); 331 | name = "[CP] Embed Pods Frameworks"; 332 | outputFileListPaths = ( 333 | "${PODS_ROOT}/Target Support Files/Pods-ContactsChangeNotifierDemo/Pods-ContactsChangeNotifierDemo-frameworks-${CONFIGURATION}-output-files.xcfilelist", 334 | ); 335 | runOnlyForDeploymentPostprocessing = 0; 336 | shellPath = /bin/sh; 337 | shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-ContactsChangeNotifierDemo/Pods-ContactsChangeNotifierDemo-frameworks.sh\"\n"; 338 | showEnvVarsInLog = 0; 339 | }; 340 | D6D8FC803B30933F54E11662 /* [CP-User] SwiftLint */ = { 341 | isa = PBXShellScriptBuildPhase; 342 | alwaysOutOfDate = 1; 343 | buildActionMask = 2147483647; 344 | files = ( 345 | ); 346 | name = "[CP-User] SwiftLint"; 347 | runOnlyForDeploymentPostprocessing = 0; 348 | shellPath = /bin/sh; 349 | shellScript = "if [ \"Debug\" == \"${CONFIGURATION}\" && ! $ENABLE_PREVIEWS == \"YES\" ]; then \"${PODS_ROOT}/SwiftLint/swiftlint\" --config \"${PODS_ROOT}/SwiftQuality/.swiftlint.yml\" \"${SRCROOT}/..\" ; fi"; 350 | }; 351 | /* End PBXShellScriptBuildPhase section */ 352 | 353 | /* Begin PBXSourcesBuildPhase section */ 354 | DCC676D928800019007156EA /* Sources */ = { 355 | isa = PBXSourcesBuildPhase; 356 | buildActionMask = 2147483647; 357 | files = ( 358 | DCC676E328800019007156EA /* ContentView.swift in Sources */, 359 | DC35A32C28801D6E0056FBFD /* Contacts+Extensions.swift in Sources */, 360 | DCC676E128800019007156EA /* ContactsChangeNotifierDemoApp.swift in Sources */, 361 | ); 362 | runOnlyForDeploymentPostprocessing = 0; 363 | }; 364 | DCF938D8288136FB006D855C /* Sources */ = { 365 | isa = PBXSourcesBuildPhase; 366 | buildActionMask = 2147483647; 367 | files = ( 368 | DCF938DF288136FB006D855C /* ContactsChangeNotifierTests.swift in Sources */, 369 | ); 370 | runOnlyForDeploymentPostprocessing = 0; 371 | }; 372 | /* End PBXSourcesBuildPhase section */ 373 | 374 | /* Begin PBXTargetDependency section */ 375 | DCF938E1288136FB006D855C /* PBXTargetDependency */ = { 376 | isa = PBXTargetDependency; 377 | target = DCC676DC28800019007156EA /* ContactsChangeNotifierDemo */; 378 | targetProxy = DCF938E0288136FB006D855C /* PBXContainerItemProxy */; 379 | }; 380 | /* End PBXTargetDependency section */ 381 | 382 | /* Begin XCBuildConfiguration section */ 383 | DCC676E92880001C007156EA /* Debug */ = { 384 | isa = XCBuildConfiguration; 385 | buildSettings = { 386 | ALWAYS_SEARCH_USER_PATHS = NO; 387 | CLANG_ANALYZER_NONNULL = YES; 388 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 389 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 390 | CLANG_ENABLE_MODULES = YES; 391 | CLANG_ENABLE_OBJC_ARC = YES; 392 | CLANG_ENABLE_OBJC_WEAK = YES; 393 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 394 | CLANG_WARN_BOOL_CONVERSION = YES; 395 | CLANG_WARN_COMMA = YES; 396 | CLANG_WARN_CONSTANT_CONVERSION = YES; 397 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 398 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 399 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 400 | CLANG_WARN_EMPTY_BODY = YES; 401 | CLANG_WARN_ENUM_CONVERSION = YES; 402 | CLANG_WARN_INFINITE_RECURSION = YES; 403 | CLANG_WARN_INT_CONVERSION = YES; 404 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 405 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 406 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 407 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 408 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 409 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 410 | CLANG_WARN_STRICT_PROTOTYPES = YES; 411 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 412 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 413 | CLANG_WARN_UNREACHABLE_CODE = YES; 414 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 415 | COPY_PHASE_STRIP = NO; 416 | DEBUG_INFORMATION_FORMAT = dwarf; 417 | ENABLE_STRICT_OBJC_MSGSEND = YES; 418 | ENABLE_TESTABILITY = YES; 419 | GCC_C_LANGUAGE_STANDARD = gnu11; 420 | GCC_DYNAMIC_NO_PIC = NO; 421 | GCC_NO_COMMON_BLOCKS = YES; 422 | GCC_OPTIMIZATION_LEVEL = 0; 423 | GCC_PREPROCESSOR_DEFINITIONS = ( 424 | "DEBUG=1", 425 | "$(inherited)", 426 | ); 427 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 428 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 429 | GCC_WARN_UNDECLARED_SELECTOR = YES; 430 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 431 | GCC_WARN_UNUSED_FUNCTION = YES; 432 | GCC_WARN_UNUSED_VARIABLE = YES; 433 | IPHONEOS_DEPLOYMENT_TARGET = 15.0; 434 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 435 | MTL_FAST_MATH = YES; 436 | ONLY_ACTIVE_ARCH = YES; 437 | SDKROOT = iphoneos; 438 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 439 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 440 | }; 441 | name = Debug; 442 | }; 443 | DCC676EA2880001C007156EA /* Release */ = { 444 | isa = XCBuildConfiguration; 445 | buildSettings = { 446 | ALWAYS_SEARCH_USER_PATHS = NO; 447 | CLANG_ANALYZER_NONNULL = YES; 448 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 449 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 450 | CLANG_ENABLE_MODULES = YES; 451 | CLANG_ENABLE_OBJC_ARC = YES; 452 | CLANG_ENABLE_OBJC_WEAK = YES; 453 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 454 | CLANG_WARN_BOOL_CONVERSION = YES; 455 | CLANG_WARN_COMMA = YES; 456 | CLANG_WARN_CONSTANT_CONVERSION = YES; 457 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 458 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 459 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 460 | CLANG_WARN_EMPTY_BODY = YES; 461 | CLANG_WARN_ENUM_CONVERSION = YES; 462 | CLANG_WARN_INFINITE_RECURSION = YES; 463 | CLANG_WARN_INT_CONVERSION = YES; 464 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 465 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 466 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 467 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 468 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 469 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 470 | CLANG_WARN_STRICT_PROTOTYPES = YES; 471 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 472 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 473 | CLANG_WARN_UNREACHABLE_CODE = YES; 474 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 475 | COPY_PHASE_STRIP = NO; 476 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 477 | ENABLE_NS_ASSERTIONS = NO; 478 | ENABLE_STRICT_OBJC_MSGSEND = YES; 479 | GCC_C_LANGUAGE_STANDARD = gnu11; 480 | GCC_NO_COMMON_BLOCKS = YES; 481 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 482 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 483 | GCC_WARN_UNDECLARED_SELECTOR = YES; 484 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 485 | GCC_WARN_UNUSED_FUNCTION = YES; 486 | GCC_WARN_UNUSED_VARIABLE = YES; 487 | IPHONEOS_DEPLOYMENT_TARGET = 15.0; 488 | MTL_ENABLE_DEBUG_INFO = NO; 489 | MTL_FAST_MATH = YES; 490 | SDKROOT = iphoneos; 491 | SWIFT_COMPILATION_MODE = wholemodule; 492 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 493 | VALIDATE_PRODUCT = YES; 494 | }; 495 | name = Release; 496 | }; 497 | DCC676EC2880001C007156EA /* Debug */ = { 498 | isa = XCBuildConfiguration; 499 | baseConfigurationReference = 4E95F5D17DA305F21E07813B /* Pods-ContactsChangeNotifierDemo.debug.xcconfig */; 500 | buildSettings = { 501 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 502 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 503 | CODE_SIGN_STYLE = Automatic; 504 | CURRENT_PROJECT_VERSION = 21; 505 | DEVELOPMENT_ASSET_PATHS = "\"ContactsChangeNotifierDemo/Preview Content\""; 506 | DEVELOPMENT_TEAM = UT7SLSWEUX; 507 | ENABLE_PREVIEWS = YES; 508 | GENERATE_INFOPLIST_FILE = YES; 509 | INFOPLIST_KEY_NSContactsUsageDescription = "To get contact changes notifications"; 510 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 511 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 512 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 513 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 514 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 515 | LD_RUNPATH_SEARCH_PATHS = ( 516 | "$(inherited)", 517 | "@executable_path/Frameworks", 518 | ); 519 | MARKETING_VERSION = 1.0; 520 | PRODUCT_BUNDLE_IDENTIFIER = io.yonat.ContactsChangeNotifierDemo; 521 | PRODUCT_NAME = "$(TARGET_NAME)"; 522 | SWIFT_EMIT_LOC_STRINGS = YES; 523 | SWIFT_VERSION = 5.0; 524 | TARGETED_DEVICE_FAMILY = "1,2"; 525 | }; 526 | name = Debug; 527 | }; 528 | DCC676ED2880001C007156EA /* Release */ = { 529 | isa = XCBuildConfiguration; 530 | baseConfigurationReference = C832BAF0520B3CF98E12A9D4 /* Pods-ContactsChangeNotifierDemo.release.xcconfig */; 531 | buildSettings = { 532 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 533 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 534 | CODE_SIGN_STYLE = Automatic; 535 | CURRENT_PROJECT_VERSION = 21; 536 | DEVELOPMENT_ASSET_PATHS = "\"ContactsChangeNotifierDemo/Preview Content\""; 537 | DEVELOPMENT_TEAM = UT7SLSWEUX; 538 | ENABLE_PREVIEWS = YES; 539 | GENERATE_INFOPLIST_FILE = YES; 540 | INFOPLIST_KEY_NSContactsUsageDescription = "To get contact changes notifications"; 541 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 542 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 543 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 544 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 545 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 546 | LD_RUNPATH_SEARCH_PATHS = ( 547 | "$(inherited)", 548 | "@executable_path/Frameworks", 549 | ); 550 | MARKETING_VERSION = 1.0; 551 | PRODUCT_BUNDLE_IDENTIFIER = io.yonat.ContactsChangeNotifierDemo; 552 | PRODUCT_NAME = "$(TARGET_NAME)"; 553 | SWIFT_EMIT_LOC_STRINGS = YES; 554 | SWIFT_VERSION = 5.0; 555 | TARGETED_DEVICE_FAMILY = "1,2"; 556 | }; 557 | name = Release; 558 | }; 559 | DCF938E2288136FB006D855C /* Debug */ = { 560 | isa = XCBuildConfiguration; 561 | baseConfigurationReference = 758511CED2F346FF2D97EDAC /* Pods-ContactsChangeNotifierTests.debug.xcconfig */; 562 | buildSettings = { 563 | BUNDLE_LOADER = "$(TEST_HOST)"; 564 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; 565 | CODE_SIGN_STYLE = Automatic; 566 | CURRENT_PROJECT_VERSION = 21; 567 | DEVELOPMENT_TEAM = UT7SLSWEUX; 568 | GENERATE_INFOPLIST_FILE = YES; 569 | MARKETING_VERSION = 1.0; 570 | PRODUCT_BUNDLE_IDENTIFIER = io.yonat.ContactsChangeNotifierTests; 571 | PRODUCT_NAME = "$(TARGET_NAME)"; 572 | SWIFT_EMIT_LOC_STRINGS = NO; 573 | SWIFT_VERSION = 5.0; 574 | TARGETED_DEVICE_FAMILY = "1,2"; 575 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/ContactsChangeNotifierDemo.app/ContactsChangeNotifierDemo"; 576 | }; 577 | name = Debug; 578 | }; 579 | DCF938E3288136FB006D855C /* Release */ = { 580 | isa = XCBuildConfiguration; 581 | baseConfigurationReference = 3E5E29F603D6F987FCD6B29C /* Pods-ContactsChangeNotifierTests.release.xcconfig */; 582 | buildSettings = { 583 | BUNDLE_LOADER = "$(TEST_HOST)"; 584 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; 585 | CODE_SIGN_STYLE = Automatic; 586 | CURRENT_PROJECT_VERSION = 21; 587 | DEVELOPMENT_TEAM = UT7SLSWEUX; 588 | GENERATE_INFOPLIST_FILE = YES; 589 | MARKETING_VERSION = 1.0; 590 | PRODUCT_BUNDLE_IDENTIFIER = io.yonat.ContactsChangeNotifierTests; 591 | PRODUCT_NAME = "$(TARGET_NAME)"; 592 | SWIFT_EMIT_LOC_STRINGS = NO; 593 | SWIFT_VERSION = 5.0; 594 | TARGETED_DEVICE_FAMILY = "1,2"; 595 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/ContactsChangeNotifierDemo.app/ContactsChangeNotifierDemo"; 596 | }; 597 | name = Release; 598 | }; 599 | /* End XCBuildConfiguration section */ 600 | 601 | /* Begin XCConfigurationList section */ 602 | DCC676D828800019007156EA /* Build configuration list for PBXProject "ContactsChangeNotifierDemo" */ = { 603 | isa = XCConfigurationList; 604 | buildConfigurations = ( 605 | DCC676E92880001C007156EA /* Debug */, 606 | DCC676EA2880001C007156EA /* Release */, 607 | ); 608 | defaultConfigurationIsVisible = 0; 609 | defaultConfigurationName = Release; 610 | }; 611 | DCC676EB2880001C007156EA /* Build configuration list for PBXNativeTarget "ContactsChangeNotifierDemo" */ = { 612 | isa = XCConfigurationList; 613 | buildConfigurations = ( 614 | DCC676EC2880001C007156EA /* Debug */, 615 | DCC676ED2880001C007156EA /* Release */, 616 | ); 617 | defaultConfigurationIsVisible = 0; 618 | defaultConfigurationName = Release; 619 | }; 620 | DCF938E4288136FB006D855C /* Build configuration list for PBXNativeTarget "ContactsChangeNotifierTests" */ = { 621 | isa = XCConfigurationList; 622 | buildConfigurations = ( 623 | DCF938E2288136FB006D855C /* Debug */, 624 | DCF938E3288136FB006D855C /* Release */, 625 | ); 626 | defaultConfigurationIsVisible = 0; 627 | defaultConfigurationName = Release; 628 | }; 629 | /* End XCConfigurationList section */ 630 | }; 631 | rootObject = DCC676D528800019007156EA /* Project object */; 632 | } 633 | -------------------------------------------------------------------------------- /Example/ContactsChangeNotifierDemo.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Example/ContactsChangeNotifierDemo.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Example/ContactsChangeNotifierDemo.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 12 | 13 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /Example/ContactsChangeNotifierDemo.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Example/ContactsChangeNotifierDemo/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Example/ContactsChangeNotifierDemo/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | } 8 | ], 9 | "info" : { 10 | "author" : "xcode", 11 | "version" : 1 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Example/ContactsChangeNotifierDemo/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Example/ContactsChangeNotifierDemo/Contacts+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Contacts+Extensions.swift 3 | // ContactsChangeNotifierDemo 4 | // 5 | // Created by Yonat Sharon on 14/07/2022. 6 | // 7 | 8 | import Contacts 9 | 10 | extension CNContact { 11 | var fullName: String { 12 | CNContactFormatter.string(from: self, style: .fullName) ?? "Unknown" 13 | } 14 | } 15 | 16 | extension CNChangeHistoryEvent { 17 | var changeDescription: String { 18 | switch self { 19 | case let addEvent as CNChangeHistoryAddContactEvent: 20 | return "Add **\(addEvent.contact.fullName)** `\(addEvent.contact.identifier)`" 21 | case let updateEvent as CNChangeHistoryUpdateContactEvent: 22 | return "Update **\(updateEvent.contact.fullName)** `\(updateEvent.contact.identifier)`" 23 | case let deleteEvent as CNChangeHistoryDeleteContactEvent: 24 | return "Delete `\(deleteEvent.contactIdentifier)`" 25 | case _ as CNChangeHistoryDropEverythingEvent: 26 | return "Initial update" 27 | default: 28 | return "Group event" 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Example/ContactsChangeNotifierDemo/ContactsChangeNotifierDemoApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContactsChangeNotifierDemoApp.swift 3 | // ContactsChangeNotifierDemo 4 | // 5 | // Created by Yonat Sharon on 14/07/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @main 11 | struct ContactsChangeNotifierDemoApp: App { 12 | var body: some Scene { 13 | WindowGroup { 14 | ContentView() 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Example/ContactsChangeNotifierDemo/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // ContactsChangeNotifierDemo 4 | // 5 | // Created by Yonat Sharon on 14/07/2022. 6 | // 7 | 8 | import ContactsChangeNotifier 9 | import SwiftUI 10 | 11 | struct ContentView: View { 12 | @State private var changes: [String] = [] 13 | @State private var changeDate: Date? 14 | 15 | let contactsChangeNotifier = try? ContactsChangeNotifier( 16 | store: CNContactStore(), 17 | fetchRequest: .fetchRequest(additionalContactKeyDescriptors: [ 18 | CNContactFormatter.descriptorForRequiredKeys(for: .fullName), 19 | ]) 20 | ) 21 | 22 | var body: some View { 23 | VStack(alignment: .leading, spacing: 16) { 24 | Text("Contact Changes:") 25 | .bold() 26 | if changes.isEmpty { 27 | Text("No changes") 28 | Text("Change some contacts outside the app, and then they will show up here.") 29 | } else { 30 | ForEach(changes.indices, id: \.self) { index in 31 | Text(LocalizedStringKey(changes[index])) 32 | } 33 | } 34 | if let changeDate = changeDate { 35 | Text("Updated: \(changeDate)") 36 | .foregroundColor(.secondary) 37 | } 38 | } 39 | .padding() 40 | .onReceive(NotificationCenter.default.publisher(for: ContactsChangeNotifier.didChangeNotification)) { notification in 41 | guard let events = notification.contactsChangeEvents else { return } 42 | changeDate = Date() 43 | changes = events.map { $0.changeDescription } 44 | } 45 | } 46 | } 47 | 48 | struct ContentView_Previews: PreviewProvider { 49 | static var previews: some View { 50 | ContentView() 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Example/ContactsChangeNotifierDemo/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Example/ContactsChangeNotifierTests/ContactsChangeNotifierTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContactsChangeNotifierTests.swift 3 | // ContactsChangeNotifierTests 4 | // 5 | // Created by Yonat Sharon on 15/07/2022. 6 | // 7 | 8 | import Contacts 9 | import ContactsChangeNotifier 10 | import XCTest 11 | 12 | class ContactsChangeNotifierTests: XCTestCase { 13 | let contactStore = CNContactStore() 14 | 15 | func testInternalUpdate() async throws { 16 | _ = NotificationCenter.default.addObserver( 17 | forName: ContactsChangeNotifier.didChangeNotification, 18 | object: nil, 19 | queue: .main 20 | ) { notification in 21 | XCTFail("Got changes when they were internal: \(notification.contactsChangeEvents ?? [])") 22 | } 23 | try await contactStore.requestAccess(for: .contacts) 24 | let notifier = try ContactsChangeNotifier(store: contactStore, fetchRequest: .fetchRequest(additionalContactKeyDescriptors: [ 25 | CNContactIdentifierKey as CNKeyDescriptor, 26 | CNContactFormatter.descriptorForRequiredKeys(for: .fullName), 27 | ])) 28 | 29 | let changeExpectation = expectation(forNotification: .CNContactStoreDidChange, object: nil) 30 | changeExpectation.assertForOverFulfill = false 31 | 32 | let newContact = CNMutableContact() 33 | newContact.givenName = "New" 34 | let saveRequest = CNSaveRequest() 35 | saveRequest.add(newContact, toContainerWithIdentifier: contactStore.defaultContainerIdentifier()) 36 | try contactStore.execute(saveRequest) 37 | 38 | wait(for: [changeExpectation], timeout: 1) 39 | 40 | // allow time for ContactsChangeNotifier.didChangeNotification to be sent: 41 | try await Task.sleep(nanoseconds: .second / 4) 42 | 43 | let changes = try notifier.changeHistory() 44 | XCTAssertEqual(changes.allObjects.count, 0, "internal change saved, and skipped by notifier") 45 | 46 | let cleanupRequest = CNSaveRequest() 47 | cleanupRequest.delete(newContact) 48 | try contactStore.execute(cleanupRequest) 49 | } 50 | } 51 | 52 | extension UInt64 { 53 | static let second: Self = 1_000_000_000 54 | } 55 | -------------------------------------------------------------------------------- /Example/Podfile: -------------------------------------------------------------------------------- 1 | use_frameworks! 2 | 3 | pod 'ContactsChangeNotifier', :path => '..' 4 | pod 'SwiftQuality', :git => 'https://github.com/yonat/SwiftQuality' 5 | 6 | target 'ContactsChangeNotifierDemo' do 7 | script_phase :name => 'SwiftFormat', 8 | :execution_position => :before_compile, 9 | :script => 'if [[ "Debug" == "${CONFIGURATION}" && ! $ENABLE_PREVIEWS == "YES" ]]; then "${PODS_ROOT}/SwiftFormat/CommandLineTool/swiftformat" --swiftversion ${SWIFT_VERSION} --config "${PODS_ROOT}/SwiftQuality/.swiftformat" "${SRCROOT}/.." ; fi' 10 | 11 | script_phase :name => 'SwiftLintAutocorrect', 12 | :execution_position => :before_compile, 13 | :script => 'if [[ "Debug" == "${CONFIGURATION}" && ! $ENABLE_PREVIEWS == "YES" ]]; then "${PODS_ROOT}/SwiftLint/swiftlint" --fix --config "${PODS_ROOT}/SwiftQuality/.swiftlint.yml" "${SRCROOT}/.." ; fi' 14 | 15 | script_phase :name => 'SwiftLint', 16 | :execution_position => :after_compile, 17 | :script => 'if [ "Debug" == "${CONFIGURATION}" && ! $ENABLE_PREVIEWS == "YES" ]; then "${PODS_ROOT}/SwiftLint/swiftlint" --config "${PODS_ROOT}/SwiftQuality/.swiftlint.yml" "${SRCROOT}/.." ; fi' 18 | end 19 | 20 | target 'ContactsChangeNotifierTests' 21 | 22 | # Fix Xcode 14 warnings "Run script build phase '[CP] _____' will be run during every build because it does not specify any outputs." 23 | # Based on https://github.com/CocoaPods/CocoaPods/issues/11444#issuecomment-1300023416 24 | post_integrate do |installer| 25 | main_project = installer.aggregate_targets[0].user_project 26 | main_project.targets.each do |target| 27 | target.build_phases.each do |phase| 28 | next unless phase.is_a?(Xcodeproj::Project::Object::PBXShellScriptBuildPhase) 29 | next unless phase.name.start_with?("[CP") 30 | next unless (phase.input_paths || []).empty? && (phase.output_paths || []).empty? 31 | phase.always_out_of_date = "1" 32 | end 33 | end 34 | main_project.save 35 | end 36 | 37 | # Workaround for Xcode 15 error DT_TOOLCHAIN_DIR cannot be used to evaluate LIBRARY_SEARCH_PATHS 38 | post_install do |installer| 39 | installer.aggregate_targets.each do |target| 40 | target.xcconfigs.each do |variant, xcconfig| 41 | xcconfig_path = target.client_root + target.xcconfig_relative_path(variant) 42 | IO.write(xcconfig_path, IO.read(xcconfig_path).gsub("DT_TOOLCHAIN_DIR", "TOOLCHAIN_DIR")) 43 | end 44 | end 45 | installer.pods_project.targets.each do |target| 46 | target.build_configurations.each do |config| 47 | if config.base_configuration_reference.is_a? Xcodeproj::Project::Object::PBXFileReference 48 | xcconfig_path = config.base_configuration_reference.real_path 49 | IO.write(xcconfig_path, IO.read(xcconfig_path).gsub("DT_TOOLCHAIN_DIR", "TOOLCHAIN_DIR")) 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /Example/Podfile.lock: -------------------------------------------------------------------------------- 1 | PODS: 2 | - ContactsChangeNotifier (1.1.0) 3 | - SwiftFormat/CLI (0.50.7) 4 | - SwiftLint (0.50.3) 5 | - SwiftQuality (2.0.1): 6 | - SwiftFormat/CLI (= 0.50.7) 7 | - SwiftLint (= 0.50.3) 8 | 9 | DEPENDENCIES: 10 | - ContactsChangeNotifier (from `..`) 11 | - SwiftQuality (from `https://github.com/yonat/SwiftQuality`) 12 | 13 | SPEC REPOS: 14 | trunk: 15 | - SwiftFormat 16 | - SwiftLint 17 | 18 | EXTERNAL SOURCES: 19 | ContactsChangeNotifier: 20 | :path: ".." 21 | SwiftQuality: 22 | :git: https://github.com/yonat/SwiftQuality 23 | 24 | CHECKOUT OPTIONS: 25 | SwiftQuality: 26 | :commit: f192ce833f082501a51269c1cb08cf6cf01fa3a3 27 | :git: https://github.com/yonat/SwiftQuality 28 | 29 | SPEC CHECKSUMS: 30 | ContactsChangeNotifier: 4aa69732e6f2dca5c618322fe230c4897ed91af1 31 | SwiftFormat: 4fcf72ee44c7198255108c22ed7135c38a36ba6b 32 | SwiftLint: 77f7cb2b9bb81ab4a12fcc86448ba3f11afa50c6 33 | SwiftQuality: 8d69820e0f82a25563c3cb33d31b0a11f124ea0c 34 | 35 | PODFILE CHECKSUM: c12a9fb7228bf37381eecd4153080533145bdc32 36 | 37 | COCOAPODS: 1.15.2 38 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 Yonat Sharon 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.6 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "ContactsChangeNotifier", 7 | platforms: [ 8 | .iOS(.v15), 9 | ], 10 | products: [ 11 | .library(name: "ContactsChangeNotifier", targets: ["ContactsChangeNotifier", "ContactStoreChangeHistory"]), 12 | ], 13 | targets: [ 14 | .target( 15 | name: "ContactStoreChangeHistory" 16 | ), 17 | .target( 18 | name: "ContactsChangeNotifier", 19 | dependencies: ["ContactStoreChangeHistory"], 20 | resources: [.process("PrivacyInfo.xcprivacy")] 21 | ), 22 | ], 23 | swiftLanguageVersions: [.v5] 24 | ) 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ContactsChangeNotifier 2 | 3 | Which contacts changed outside your iOS app? Better `CNContactStoreDidChange` notification: Get real changes, without the noise. 4 | 5 | [![Swift Version][swift-image]][swift-url] 6 | [![License][license-image]][license-url] 7 | [![CocoaPods Compatible](https://img.shields.io/cocoapods/v/ContactsChangeNotifier.svg)](https://img.shields.io/cocoapods/v/ContactsChangeNotifier.svg) 8 | [![Platform](https://img.shields.io/cocoapods/p/ContactsChangeNotifier.svg?style=flat)](http://cocoapods.org/pods/ContactsChangeNotifier) 9 | [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](http://makeapullrequest.com) 10 | 11 | 12 | ## Why Oh Why 13 | 14 | Sadly, the Contacts changes API is a mess: 15 | 16 | - The `CNContactStoreDidChange` notification is received for changes your own code did, not just outside your app. 🤷 17 | - It contains undocumented `userInfo` fields. 🙈 18 | - To get the actual changes, you need to use an Objective-C API that is not even callable from Swift. 😱 19 | - That API is easy to get wrong, and requires maintaining opaque state, or receiving the complete changes history. 🧨 20 | 21 | It’s the API that time forgot. 🧟‍♂️ 22 | 23 | ## ContactsChangeNotifier Features 24 | 25 | * Only get notified for changes outside your app. 🎯 26 | * Get the list of changes included in the notification. 🎁 27 | * Only get changes since last notification, not the full all-time history. ✨ 28 | * No Objective-C required. 💥 29 | 30 | ## Usage 31 | 32 | 1. Get the user's Contacts access permission (see [docs](https://developer.apple.com/documentation/contacts/requesting_authorization_to_access_contacts)). 33 | 2. Keep a `ContactsChangeNotifier` instance - 34 | it will observe all Contacts changes but post only those that from outside your app. 35 | 3. Observe `ContactsChangeNotifier.didChangeNotification` notification. 36 | 4. See change events in the notification's `contactsChangeEvents`. 37 | 38 | ```swift 39 | // 2. Keep a ContactsChangeNotifier instance 40 | let contactsChangeNotifier = try! ContactsChangeNotifier( 41 | store: myCNContactStore, 42 | fetchRequest: .fetchRequest(additionalContactKeyDescriptors: myCNKeyDescriptors) 43 | ) 44 | 45 | // 3. Observe ContactsChangeNotifier.didChangeNotification notification 46 | let observation = NotificationCenter.default.addObserver( 47 | forName: ContactsChangeNotifier.didChangeNotification, 48 | object: nil, 49 | queue: nil 50 | ) { notification in 51 | // 4. See change events in the notification's contactsChangeEvents 52 | for event in notification.contactsChangeEvents ?? [] { 53 | switch event { 54 | case let addEvent as CNChangeHistoryAddContactEvent: 55 | print(addEvent.contact) 56 | case let updateEvent as CNChangeHistoryUpdateContactEvent: 57 | print(updateEvent.contact) 58 | case let deleteEvent as CNChangeHistoryDeleteContactEvent: 59 | print(deleteEvent.contactIdentifier) 60 | default: 61 | // group event 62 | break 63 | } 64 | } 65 | } 66 | ``` 67 | 68 | ## Installation 69 | 70 | ### CocoaPods: 71 | 72 | ```ruby 73 | pod 'ContactsChangeNotifier' 74 | ``` 75 | 76 | ### Swift Package Manager: 77 | 78 | ```swift 79 | dependencies: [ 80 | .package(url: "https://github.com/yonat/ContactsChangeNotifier", from: "1.2.0") 81 | ] 82 | ``` 83 | 84 | [swift-image]:https://img.shields.io/badge/swift-5.0-orange.svg 85 | [swift-url]: https://swift.org/ 86 | [license-image]: https://img.shields.io/badge/License-MIT-blue.svg 87 | [license-url]: LICENSE.txt 88 | -------------------------------------------------------------------------------- /Screenshots/ContactsChanges.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yonat/ContactsChangeNotifier/6075fd769fbdd3d23140f81ada82f71a22c444b7/Screenshots/ContactsChanges.png -------------------------------------------------------------------------------- /Sources/ContactStoreChangeHistory/CNContactStore+ChangeHistory.h: -------------------------------------------------------------------------------- 1 | // 2 | // CNContactStore+ChangeHistory.h 3 | // CallAbout 4 | // 5 | // Created by Yonat Sharon on 06/07/2022. 6 | // 7 | 8 | #import 9 | 10 | NS_ASSUME_NONNULL_BEGIN 11 | 12 | @interface CNContactStore (ChangeHistory) 13 | 14 | /// Access `enumeratorForContactFetchRequest:error:` from Swift - Enumerates a change history fetch request. 15 | /// @param request A description of the events to fetch. 16 | /// @param error If the fetch fails, contains an `NSError` object with more information. 17 | /// @return An enumerator of the events matching the result, or nil if there was an error. 18 | - (CNFetchResult *> *)swiftEnumeratorForChangeHistoryFetchRequest:(CNChangeHistoryFetchRequest *)request 19 | error:(NSError * _Nullable *)error; 20 | @end 21 | 22 | NS_ASSUME_NONNULL_END 23 | -------------------------------------------------------------------------------- /Sources/ContactStoreChangeHistory/CNContactStore+ChangeHistory.m: -------------------------------------------------------------------------------- 1 | // 2 | // CNContactStore+ChangeHistory.m 3 | // CallAbout 4 | // 5 | // Created by Yonat Sharon on 06/07/2022. 6 | // 7 | 8 | #import "CNContactStore+ChangeHistory.h" 9 | 10 | @implementation CNContactStore (ChangeHistory) 11 | 12 | - (CNFetchResult *> *)swiftEnumeratorForChangeHistoryFetchRequest:(CNChangeHistoryFetchRequest *)request 13 | error:(NSError * _Nullable *)error 14 | { 15 | return [self enumeratorForChangeHistoryFetchRequest:request error:error]; 16 | } 17 | 18 | @end 19 | -------------------------------------------------------------------------------- /Sources/ContactStoreChangeHistory/include/ContactStoreChangeHistory.h: -------------------------------------------------------------------------------- 1 | #import "../CNContactStore+ChangeHistory.h" 2 | -------------------------------------------------------------------------------- /Sources/ContactsChangeNotifier/ContactsChangeNotifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContactsChangeNotifier.swift 3 | // 4 | // Created by Yonat Sharon on 10/07/2022. 5 | // 6 | 7 | @preconcurrency import Contacts 8 | import UIKit 9 | 10 | #if !COCOAPODS 11 | import ContactStoreChangeHistory 12 | #endif 13 | 14 | public extension Notification { 15 | internal static let contactsChangeEventsKey = "ContactsChangeEvents" 16 | 17 | /// Contacts change events in a ``ContactsChangeNotifier.didChangeNotification`` 18 | var contactsChangeEvents: [CNChangeHistoryEvent]? { 19 | userInfo?[Self.contactsChangeEventsKey] as? [CNChangeHistoryEvent] 20 | } 21 | } 22 | 23 | public extension CNChangeHistoryFetchRequest { 24 | /// Creates a request with sensible defaults: 25 | /// Only retrieve contact identifiers, and ignore changes with the `transactionAuthor == Bundle.main.bundleIdentifier`. 26 | /// Pass parameters to override defaults. 27 | static func fetchRequest( 28 | shouldUnifyResults: Bool = true, 29 | includeGroupChanges: Bool = true, 30 | excludedTransactionAuthors: [String]? = Bundle.main.bundleIdentifier.flatMap { [$0] }, 31 | additionalContactKeyDescriptors: [CNKeyDescriptor] = [] 32 | ) -> CNChangeHistoryFetchRequest { 33 | let request = CNChangeHistoryFetchRequest() 34 | request.shouldUnifyResults = shouldUnifyResults 35 | request.includeGroupChanges = includeGroupChanges 36 | request.excludedTransactionAuthors = excludedTransactionAuthors 37 | request.additionalContactKeyDescriptors = additionalContactKeyDescriptors 38 | return request 39 | } 40 | } 41 | 42 | /// Posts notifications of *external* changes in Contacts (i.e., changes made outside the app). **Note**: Requires user contacts authorization. 43 | /// 44 | /// To use, keep a`ContactsChangeNotifier` object, and observe ``ContactsChangeNotifier.didChangeNotification`` notifications. 45 | /// 46 | /// Example: 47 | /// 48 | /// ```swift 49 | /// let notifier = ContactsChangeNotifier(store: myCNContactStore) 50 | /// 51 | /// init() { 52 | /// NotificationCenter.default.addObserver( 53 | /// self, 54 | /// selector: #selector(contactsStoreChanged), // func that handles change notifications 55 | /// name: ContactsChangeNotifier.didChangeNotification, 56 | /// object: nil 57 | /// ) 58 | /// } 59 | /// ``` 60 | public final class ContactsChangeNotifier: NSObject, Sendable { 61 | /// Posted when *external* changes occur in Contacts (i.e., changes made outside the app). Includes `contactsChangeEvents` with all changes. 62 | /// 63 | /// Replaces `CNContactStoreDidChange` which is called both for internal changes and for phantom echoes of changes. 64 | public static let didChangeNotification = Notification.Name("ContactsChangeNotifier.didChangeNotification") 65 | 66 | public let store: CNContactStore 67 | 68 | /// Spec of which changes to observe. 69 | /// 70 | /// `startingToken` is ignored: `lastHistoryToken` will be automatically used. 71 | /// 72 | /// Use `.fetchRequest()` for sensible defaults. 73 | public let fetchRequest: CNChangeHistoryFetchRequest 74 | 75 | /// The location where `lastHistoryToken` is stored. 76 | public let historyTokenStorage: HistoryTokenStorage 77 | 78 | /// Used as `startingToken` when fetching Contacts change history. 79 | /// Updated after every fetch, to avoid getting the same changes over and over again. 80 | public var lastHistoryToken: Data? { 81 | get { historyTokenStorage.tokenData } 82 | set { historyTokenStorage.tokenData = newValue } 83 | } 84 | 85 | /// Create a notifier of *external* changes in Contacts (i.e., changes made outside the app). **Note**: Requires user contacts authorization. 86 | /// 87 | /// > Warning: To use `iCloudKeyValueStore` as the `lastHistoryToken` storage type, 88 | /// > add "iCloud" to "Signing & Capabilities" and enable "Key-value storage". 89 | /// 90 | /// - Parameters: 91 | /// - store: The contacts store to use 92 | /// - historyTokenStorage: Where `lastHistoryToken` is stored: `.userDefaults(suiteName:)` or `.iCloudKeyValueStore` or your own storage implementation. 93 | /// - fetchRequest: Optional spec of which changes to observe. 94 | /// 95 | /// `fetchRequest.startingToken` is ignored, `lastHistoryToken` will be used instead. 96 | public init( 97 | store: CNContactStore, 98 | historyTokenStorage: HistoryTokenStorage = .userDefaults, 99 | fetchRequest: CNChangeHistoryFetchRequest = .fetchRequest() 100 | ) throws { 101 | self.store = store 102 | self.historyTokenStorage = historyTokenStorage 103 | self.fetchRequest = fetchRequest 104 | super.init() 105 | Task { 106 | try await setupContactStore() 107 | } 108 | } 109 | 110 | /// Get changes in Contacts. 111 | /// - Parameter fetchRequest: Optional change history request. 112 | /// By default, uses `self.fetchRequest` with `lastHistoryToken`, so will return only changes made since the last call. 113 | /// Passing a request with nil `startingToken` will return all contacts and groups. 114 | /// - Returns: An enumerator of ``CNChangeHistoryEvent`` objects. 115 | public func changeHistory(fetchRequest: CNChangeHistoryFetchRequest? = nil) throws -> NSEnumerator { 116 | let fetchRequest = fetchRequest ?? { 117 | self.fetchRequest.startingToken = lastHistoryToken 118 | return self.fetchRequest 119 | }() 120 | var error: NSError? 121 | let fetchResult = store.swiftEnumerator(for: fetchRequest, error: &error) 122 | if let error = error { throw error } 123 | return fetchResult.value 124 | } 125 | 126 | // MARK: - Privates 127 | 128 | @MainActor private var observation: NSObjectProtocol? 129 | 130 | private func setupContactStore() async throws { 131 | try await store.requestAccess(for: .contacts) 132 | 133 | // wake up store, otherwise change notification not received 134 | _ = store.defaultContainerIdentifier() 135 | 136 | // don't get changes that occurred before app was ever run 137 | if nil == lastHistoryToken { 138 | lastHistoryToken = store.currentHistoryToken 139 | } else { // get changes since the last update 140 | Task.detached(priority: .background) { [weak self] in 141 | self?.forwardChangeHistoryEvents() 142 | } 143 | } 144 | 145 | await MainActor.run { 146 | observation = NotificationCenter.default.addObserver( 147 | forName: .CNContactStoreDidChange, 148 | object: nil, 149 | queue: .main 150 | ) { [weak self] notification in 151 | self?.contactsStoreChanged(isExternal: notification.isContactsStoreChangeExternal) 152 | } 153 | } 154 | } 155 | 156 | @Sendable @objc private func contactsStoreChanged(isExternal: Bool) { 157 | // avoid phantom echoes of internal changes by checking `applicationState`: 158 | // .background => called from background refresh => external change 159 | // .inactive => called when app opened => external change 160 | // .active => regular app execution => internal change 161 | Task { @MainActor in 162 | guard isExternal, !applicateStateIsActive() else { 163 | lastHistoryToken = store.currentHistoryToken 164 | return 165 | } 166 | 167 | Task.detached(priority: .background) { [weak self] in 168 | self?.forwardChangeHistoryEvents() 169 | } 170 | } 171 | } 172 | 173 | @MainActor 174 | private func applicateStateIsActive() -> Bool { 175 | UIApplication.safeShared?.applicationState == .active 176 | } 177 | 178 | /// Get contacts change events and post them in a `didChangeNotification` 179 | private func forwardChangeHistoryEvents() { 180 | do { 181 | let changes = try changeHistory() 182 | lastHistoryToken = store.currentHistoryToken 183 | let changeHistoryEvents = changes.compactMap { $0 as? CNChangeHistoryEvent } 184 | guard !changeHistoryEvents.isEmpty else { return } 185 | Task { @MainActor [weak self] in 186 | self?.postNotification(changeHistoryEvents: changeHistoryEvents) 187 | } 188 | } catch { 189 | #if DEBUG 190 | print("ContactsChangeNotifier failed to get Contacts change history:", error.localizedDescription) 191 | #endif 192 | } 193 | } 194 | 195 | private func postNotification(changeHistoryEvents: [CNChangeHistoryEvent]) { 196 | NotificationCenter.default.post( 197 | name: Self.didChangeNotification, 198 | object: self, 199 | userInfo: [Notification.contactsChangeEventsKey: changeHistoryEvents] 200 | ) 201 | } 202 | } 203 | 204 | // Applies to .CNContactStoreDidChange 205 | private extension Notification { 206 | /// (Undocumented) Did the change originate outside the app 207 | var isContactsStoreChangeExternal: Bool { 208 | nil != userInfo?["CNNotificationOriginationExternally"] 209 | } 210 | 211 | /// (Undocumented) Empty for external-to-app changes, some `CNDataMapperContactStore` for internal changes. 212 | var contactsStoreChangeSources: NSArray { 213 | userInfo?["CNNotificationSourcesKey"] as? NSArray ?? [] 214 | } 215 | 216 | /// (Undocumented) Empty for external-to-app changes, something like `["CA37C0B8-85A0-49BF-A03B-3F3C40C2CF8E"]` for internal changes. 217 | var contactsStoreChangeIdentifiers: [String] { 218 | (userInfo?["CNNotificationSaveIdentifiersKey"] as? NSArray ?? []) 219 | .compactMap { $0 as? String } 220 | } 221 | } 222 | 223 | // From https://stackoverflow.com/a/69153780/1176162 224 | extension UIApplication { 225 | static var safeShared: UIApplication? { 226 | guard UIApplication.responds(to: Selector(("sharedApplication"))) else { 227 | return nil 228 | } 229 | 230 | guard let unmanagedSharedApplication = UIApplication.perform(Selector(("sharedApplication"))) else { 231 | return nil 232 | } 233 | 234 | return unmanagedSharedApplication.takeUnretainedValue() as? UIApplication 235 | } 236 | } 237 | -------------------------------------------------------------------------------- /Sources/ContactsChangeNotifier/HistoryTokenStorage/CloudKitHistoryTokenStorage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CloudKitHistoryTokenStorage.swift 3 | // ContactsChangeNotifier 4 | // 5 | // Created by JP Toro on 10/19/24. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A concrete implementation of `HistoryTokenStorage` that uses CloudKit (NSUbiquitousKeyValueStore) for storing history tokens. 11 | public final class CloudKitHistoryTokenStorage: HistoryTokenStorage { 12 | public var tokenData: Data? { 13 | get { 14 | NSUbiquitousKeyValueStore.default.data(forKey: lastHistoryTokenUserDefaultsKey) 15 | } 16 | set { 17 | NSUbiquitousKeyValueStore.default.set(newValue, forKey: lastHistoryTokenUserDefaultsKey) 18 | } 19 | } 20 | } 21 | 22 | public extension HistoryTokenStorage where Self == CloudKitHistoryTokenStorage { 23 | /// Creates a CloudKitHistoryTokenStorage instance using the default iCloud key-value store. 24 | static var iCloudKeyValueStore: CloudKitHistoryTokenStorage { CloudKitHistoryTokenStorage() } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/ContactsChangeNotifier/HistoryTokenStorage/HistoryTokenStorage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HistoryTokenStorage.swift 3 | // ContactsChangeNotifier 4 | // 5 | // Created by JP Toro on 9/28/24. 6 | // 7 | 8 | import Foundation 9 | 10 | /// The key used to store the last history token in UserDefaults or NSUbiquitousKeyValueStore. 11 | let lastHistoryTokenUserDefaultsKey = "ContactsChangeNotifier.lastHistoryToken" 12 | 13 | /// A protocol for managing the storage of history tokens returned from `CNChangeHistoryFetchRequest`. 14 | /// 15 | /// Conform to `HistoryTokenStorage` to implement custom storage for these tokens. 16 | public protocol HistoryTokenStorage: Sendable, AnyObject { 17 | /// A `Data` object representing the history token, or `nil` if no token is stored. 18 | var tokenData: Data? { get set } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/ContactsChangeNotifier/HistoryTokenStorage/UserDefaultsHistoryTokenStorage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserDefaultsHistoryTokenStorage.swift 3 | // ContactsChangeNotifier 4 | // 5 | // Created by JP Toro on 10/19/24. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A concrete implementation of `HistoryTokenStorage` that uses UserDefaults for storing history tokens. 11 | public final class UserDefaultsHistoryTokenStorage: HistoryTokenStorage { 12 | /// The name of the UserDefaults suite to use. 13 | private let suiteName: String? 14 | 15 | /// Initializes a new instance of `UserDefaultsHistoryTokenStorage`. 16 | /// 17 | /// - Parameter suiteName: The name of the UserDefaults suite to use. Default is `nil`. 18 | public init(suiteName: String? = nil) { 19 | self.suiteName = suiteName 20 | } 21 | 22 | public var tokenData: Data? { 23 | get { 24 | UserDefaults(suiteName: suiteName)?.data(forKey: lastHistoryTokenUserDefaultsKey) 25 | } 26 | set { 27 | UserDefaults(suiteName: suiteName)?.set(newValue, forKey: lastHistoryTokenUserDefaultsKey) 28 | } 29 | } 30 | } 31 | 32 | public extension HistoryTokenStorage where Self == UserDefaultsHistoryTokenStorage { 33 | /// Creates a UserDefaultsHistoryTokenStorage instance using the standard UserDefaults. 34 | static var userDefaults: UserDefaultsHistoryTokenStorage { UserDefaultsHistoryTokenStorage() } 35 | 36 | /// Creates a UserDefaultsHistoryTokenStorage instance with a specific suite name. 37 | /// - Parameter suiteName: The name of the UserDefaults suite to use. 38 | /// - Returns: A UserDefaultsHistoryTokenStorage instance configured with the specified suite name. 39 | static func userDefaults(suiteName: String) -> UserDefaultsHistoryTokenStorage { 40 | UserDefaultsHistoryTokenStorage(suiteName: suiteName) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Sources/ContactsChangeNotifier/PrivacyInfo.xcprivacy: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSPrivacyAccessedAPITypes 6 | 7 | 8 | NSPrivacyAccessedAPIType 9 | NSPrivacyAccessedAPICategoryUserDefaults 10 | NSPrivacyAccessedAPITypeReasons 11 | 12 | CA92.1 13 | 14 | 15 | 16 | NSPrivacyTracking 17 | 18 | NSPrivacyCollectedDataTypes 19 | 20 | NSPrivacyTrackingDomains 21 | 22 | 23 | 24 | --------------------------------------------------------------------------------