@cap-kit/people 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CapKitPeople.podspec +20 -0
  2. package/LICENSE +21 -0
  3. package/Package.swift +28 -0
  4. package/README.md +1177 -0
  5. package/android/build.gradle +101 -0
  6. package/android/src/main/AndroidManifest.xml +4 -0
  7. package/android/src/main/java/io/capkit/people/PeopleImpl.kt +1003 -0
  8. package/android/src/main/java/io/capkit/people/PeopleObserver.kt +80 -0
  9. package/android/src/main/java/io/capkit/people/PeoplePlugin.kt +766 -0
  10. package/android/src/main/java/io/capkit/people/config/PeopleConfig.kt +44 -0
  11. package/android/src/main/java/io/capkit/people/error/PeopleError.kt +90 -0
  12. package/android/src/main/java/io/capkit/people/error/PeopleErrorMessages.kt +39 -0
  13. package/android/src/main/java/io/capkit/people/logger/PeopleLogger.kt +85 -0
  14. package/android/src/main/java/io/capkit/people/models/ContactModels.kt +64 -0
  15. package/android/src/main/java/io/capkit/people/utils/PeopleUtils.kt +133 -0
  16. package/android/src/main/res/.gitkeep +0 -0
  17. package/dist/docs.json +1449 -0
  18. package/dist/esm/definitions.d.ts +775 -0
  19. package/dist/esm/definitions.js +31 -0
  20. package/dist/esm/definitions.js.map +1 -0
  21. package/dist/esm/index.d.ts +15 -0
  22. package/dist/esm/index.js +18 -0
  23. package/dist/esm/index.js.map +1 -0
  24. package/dist/esm/web.d.ts +120 -0
  25. package/dist/esm/web.js +252 -0
  26. package/dist/esm/web.js.map +1 -0
  27. package/dist/plugin.cjs +300 -0
  28. package/dist/plugin.cjs.map +1 -0
  29. package/dist/plugin.js +303 -0
  30. package/dist/plugin.js.map +1 -0
  31. package/ios/Sources/PeoplePlugin/PeopleImpl.swift +463 -0
  32. package/ios/Sources/PeoplePlugin/PeoplePlugin.swift +627 -0
  33. package/ios/Sources/PeoplePlugin/PrivacyInfo.xcprivacy +13 -0
  34. package/ios/Sources/PeoplePlugin/Utils/PeopleUtils.swift +120 -0
  35. package/ios/Sources/PeoplePlugin/Version.swift +16 -0
  36. package/ios/Sources/PeoplePlugin/config/PeopleConfig.swift +56 -0
  37. package/ios/Sources/PeoplePlugin/error/PeopleError.swift +89 -0
  38. package/ios/Sources/PeoplePlugin/error/PeopleErrorMessages.swift +25 -0
  39. package/ios/Sources/PeoplePlugin/logger/PeopleLogging.swift +69 -0
  40. package/ios/Sources/PeoplePlugin/models/ContactModels.swift +68 -0
  41. package/ios/Tests/PeoplePluginTests/PeoplePluginTests.swift +10 -0
  42. package/package.json +119 -0
@@ -0,0 +1,627 @@
1
+ import Foundation
2
+ import Capacitor
3
+ import Contacts
4
+ import ContactsUI
5
+
6
+ /**
7
+ Capacitor Bridge for People.
8
+ Implements CNContactPickerDelegate for Zero-Permission access.
9
+ */
10
+ @objc(PeoplePlugin)
11
+ public class PeoplePlugin: CAPPlugin, CAPBridgedPlugin, CNContactPickerDelegate {
12
+
13
+ // MARK: - Properties
14
+
15
+ /// An instance of the implementation class that contains the plugin's core functionality.
16
+ private let implementation = PeopleImpl()
17
+
18
+ /// Internal storage for the plugin configuration read from capacitor.config.ts.
19
+ private var config: PeopleConfig?
20
+
21
+ /// The unique identifier for the plugin used by the Capacitor bridge.
22
+ public let identifier = "PeoplePlugin"
23
+
24
+ /// The name used to reference this plugin in JavaScript.
25
+ public let jsName = "People"
26
+
27
+ // State for the active picker call
28
+ private var savedPickCall: CAPPluginCall?
29
+
30
+ // State for observing changes (not yet implemented)
31
+ private var peopleObserverActive = false
32
+
33
+ // Stores the NotificationCenter observer token so it can be removed reliably
34
+ private var peopleObserverToken: NSObjectProtocol?
35
+
36
+ private func beginPickCall(_ call: CAPPluginCall) -> Bool {
37
+ objc_sync_enter(self)
38
+ defer { objc_sync_exit(self) }
39
+ if savedPickCall != nil { return false }
40
+ savedPickCall = call
41
+ return true
42
+ }
43
+
44
+ private func clearPickCall() {
45
+ objc_sync_enter(self)
46
+ savedPickCall = nil
47
+ objc_sync_exit(self)
48
+ }
49
+
50
+ /**
51
+ * A list of methods exposed by this plugin. These methods can be called from the JavaScript side.
52
+ */
53
+ public let pluginMethods: [CAPPluginMethod] = [
54
+ CAPPluginMethod(name: "getCapabilities", returnType: CAPPluginReturnPromise),
55
+ CAPPluginMethod(name: "pickContact", returnType: CAPPluginReturnPromise),
56
+ CAPPluginMethod(name: "getContact", returnType: CAPPluginReturnPromise),
57
+ CAPPluginMethod(name: "getContacts", returnType: CAPPluginReturnPromise),
58
+ CAPPluginMethod(name: "searchPeople", returnType: CAPPluginReturnPromise),
59
+ CAPPluginMethod(name: "listGroups", returnType: CAPPluginReturnPromise),
60
+ CAPPluginMethod(name: "createGroup", returnType: CAPPluginReturnPromise),
61
+ CAPPluginMethod(name: "deleteGroup", returnType: CAPPluginReturnPromise),
62
+ CAPPluginMethod(name: "addPeopleToGroup", returnType: CAPPluginReturnPromise),
63
+ CAPPluginMethod(name: "removePeopleFromGroup", returnType: CAPPluginReturnPromise),
64
+ CAPPluginMethod(name: "createContact", returnType: CAPPluginReturnPromise),
65
+ CAPPluginMethod(name: "updateContact", returnType: CAPPluginReturnPromise),
66
+ CAPPluginMethod(name: "deleteContact", returnType: CAPPluginReturnPromise),
67
+ CAPPluginMethod(name: "mergeContacts", returnType: CAPPluginReturnPromise),
68
+ CAPPluginMethod(name: "checkPermissions", returnType: CAPPluginReturnPromise),
69
+ CAPPluginMethod(name: "requestPermissions", returnType: CAPPluginReturnPromise),
70
+ CAPPluginMethod(name: "addListener", returnType: CAPPluginReturnNone),
71
+ CAPPluginMethod(name: "removeListener", returnType: CAPPluginReturnNone),
72
+ CAPPluginMethod(name: "removeAllListeners", returnType: CAPPluginReturnNone),
73
+ CAPPluginMethod(name: "getPluginVersion", returnType: CAPPluginReturnPromise)
74
+ ]
75
+
76
+ // MARK: - Lifecycle
77
+
78
+ /**
79
+ * Plugin lifecycle entry point.
80
+ *
81
+ * Called once when the plugin is loaded. This method initializes the configuration
82
+ * and prepares the native implementation.
83
+ */
84
+ override public func load() {
85
+ // Initialize PeopleConfig with the correct type
86
+ let cfg = PeopleConfig(plugin: self)
87
+ self.config = cfg
88
+ implementation.applyConfig(cfg)
89
+
90
+ // Log if verbose logging is enabled
91
+ PeopleLogger.debug("Plugin loaded.")
92
+ }
93
+
94
+ deinit {
95
+ // Ensure NotificationCenter observers are always removed
96
+ stopPeopleObserver()
97
+ }
98
+
99
+ // MARK: - Error Mapping
100
+
101
+ /**
102
+ * Rejects the call using standardized error codes from the native PeopleError enum.
103
+ */
104
+ private func reject(
105
+ _ call: CAPPluginCall,
106
+ error: PeopleError
107
+ ) {
108
+ // Use the centralized errorCode and message defined in PeopleError.swift
109
+ call.reject(error.message, error.errorCode)
110
+ }
111
+
112
+ private func handleError(_ call: CAPPluginCall, _ error: Error) {
113
+ if let peopleError = error as? PeopleError {
114
+ call.reject(peopleError.message, peopleError.errorCode)
115
+ } else {
116
+ reject(call, error: .initFailed(error.localizedDescription))
117
+ }
118
+ }
119
+
120
+ private func ensureContactsPermission(_ call: CAPPluginCall) -> Bool {
121
+ let status = CNContactStore.authorizationStatus(for: .contacts)
122
+ if #available(iOS 18.0, *) {
123
+ if status == .authorized || status == .limited {
124
+ return true
125
+ }
126
+ } else if status == .authorized {
127
+ return true
128
+ }
129
+ reject(call, error: .permissionDenied("Permission denied"))
130
+ return false
131
+ }
132
+
133
+ private func hasWritableCreateContactField(_ contactData: [String: Any]) -> Bool {
134
+ let writableKeys: Set<String> = [
135
+ "name", "organization", "birthday",
136
+ "phones", "emails", "addresses", "urls",
137
+ "note", "image"
138
+ ]
139
+
140
+ for key in writableKeys {
141
+ guard let value = contactData[key] else { continue }
142
+
143
+ if let dict = value as? [String: Any], !dict.isEmpty { return true }
144
+ if let array = value as? [Any], !array.isEmpty { return true }
145
+ if let stringValue = value as? String, !stringValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return true }
146
+ }
147
+ return false
148
+ }
149
+
150
+ // MARK: - Capabilities
151
+
152
+ /// Retrieves the plugin capabilities based on authorization status.
153
+ @objc func getCapabilities(_ call: CAPPluginCall) {
154
+ let status = CNContactStore.authorizationStatus(for: .contacts)
155
+ let caps = implementation.getCapabilities(authStatus: status)
156
+ call.resolve(caps)
157
+ }
158
+
159
+ // MARK: - Zero-Permission Picker
160
+
161
+ /// Opens the contact picker for user to select a contact.
162
+ @objc func pickContact(_ call: CAPPluginCall) {
163
+ if !beginPickCall(call) {
164
+ return reject(call, error: .conflict("Another pickContact call is already in progress"))
165
+ }
166
+
167
+ // 1. Extract and validate projection before opening the UI
168
+ let projection = call.getArray("projection", String.self) ?? ["name", "phones", "emails"]
169
+
170
+ do {
171
+ try PeopleUtils.validateProjection(projection)
172
+ } catch {
173
+ clearPickCall()
174
+ handleError(call, error)
175
+ return
176
+ }
177
+
178
+ // Dispatch UI to Main Thread
179
+ DispatchQueue.main.async { [weak self] in
180
+ guard let self = self, let viewController = self.bridge?.viewController else {
181
+ self?.clearPickCall()
182
+ self?.reject(call, error: .initFailed("Unable to access ViewController"))
183
+ return
184
+ }
185
+
186
+ let picker = CNContactPickerViewController()
187
+ picker.delegate = self
188
+
189
+ viewController.present(picker, animated: true, completion: nil)
190
+ }
191
+ }
192
+
193
+ // MARK: - CNContactPickerDelegate
194
+
195
+ /// User selected a contact
196
+ public func contactPicker(_ picker: CNContactPickerViewController, didSelect contact: CNContact) {
197
+ guard let call = self.savedPickCall else { return }
198
+ defer { clearPickCall() }
199
+ let projectionJS = call.getArray("projection", String.self) ?? ["name", "phones", "emails"]
200
+
201
+ // Use static mapper from Utils instead of implementation member
202
+ let contactData = PeopleUtils.mapToContactData(contact, projection: projectionJS)
203
+ call.resolve(["contact": contactToJS(contactData)])
204
+ }
205
+
206
+ /// User cancelled
207
+ public func contactPickerDidCancel(_ picker: CNContactPickerViewController) {
208
+ guard let call = self.savedPickCall else { return }
209
+ defer { clearPickCall() }
210
+ self.reject(call, error: .cancelled("User cancelled selection"))
211
+ }
212
+
213
+ // MARK: - Event Listeners
214
+
215
+ /// Adds a listener for contact changes.
216
+ @objc override public func addListener(_ call: CAPPluginCall) {
217
+ guard let eventName = call.getString("eventName"), !eventName.isEmpty else {
218
+ return reject(call, error: .invalidInput(PeopleErrorMessages.eventNameRequired))
219
+ }
220
+
221
+ guard eventName == "peopleChange" else {
222
+ return reject(call, error: .invalidInput(PeopleErrorMessages.unsupportedEventName(eventName)))
223
+ }
224
+
225
+ if !ensureContactsPermission(call) { return }
226
+
227
+ call.keepAlive = true
228
+ super.addListener(call)
229
+ if !peopleObserverActive { startPeopleObserver() }
230
+ }
231
+
232
+ /// Remove a listener for contact changes.
233
+ @objc override public func removeListener(_ call: CAPPluginCall) {
234
+ super.removeListener(call)
235
+
236
+ // Release the listener call to prevent retained calls and potential leaks.
237
+ bridge?.releaseCall(withID: call.callbackId)
238
+
239
+ if !hasListeners("peopleChange") {
240
+ stopPeopleObserver()
241
+ }
242
+ }
243
+
244
+ /// Removes all listeners for contact changes.
245
+ @objc override public func removeAllListeners(_ call: CAPPluginCall) {
246
+ super.removeAllListeners(call)
247
+ stopPeopleObserver()
248
+ }
249
+
250
+ private func startPeopleObserver() {
251
+ guard !peopleObserverActive else { return }
252
+ peopleObserverToken = NotificationCenter.default.addObserver(
253
+ forName: .CNContactStoreDidChange,
254
+ object: nil,
255
+ queue: .main
256
+ ) { [weak self] _ in
257
+ guard let self = self else { return }
258
+
259
+ // Ensure a consistent payload for "peopleChange" listeners.
260
+ // Always include "ids" as an array (may be empty) and a "type" string with default "update".
261
+ if self.hasListeners("peopleChange") {
262
+ // iOS does not provide specific IDs in CNContactStoreDidChange.
263
+ let changedIds: [String] = []
264
+ let eventType: String = "update"
265
+
266
+ self.notifyListeners("peopleChange", data: [
267
+ "ids": changedIds,
268
+ "type": eventType
269
+ ])
270
+ } else {
271
+ self.stopPeopleObserver()
272
+ }
273
+ }
274
+ peopleObserverActive = true
275
+ }
276
+
277
+ private func stopPeopleObserver() {
278
+ if let token = peopleObserverToken {
279
+ NotificationCenter.default.removeObserver(token)
280
+ peopleObserverToken = nil
281
+ }
282
+ peopleObserverActive = false
283
+ }
284
+
285
+ // MARK: - Permissions
286
+
287
+ /**
288
+ * Checks permission status.
289
+ */
290
+ @objc override public func checkPermissions(_ call: CAPPluginCall) {
291
+ let status = CNContactStore.authorizationStatus(for: .contacts)
292
+ var state: String
293
+ if #available(iOS 18.0, *) {
294
+ switch status {
295
+ case .authorized, .limited: state = "granted"
296
+ case .denied, .restricted: state = "denied"
297
+ case .notDetermined: state = "prompt"
298
+ @unknown default: state = "prompt"
299
+ }
300
+ } else {
301
+ switch status {
302
+ case .authorized: state = "granted"
303
+ case .denied, .restricted: state = "denied"
304
+ case .notDetermined: state = "prompt"
305
+ @unknown default: state = "prompt"
306
+ }
307
+ }
308
+ call.resolve(["contacts": state])
309
+ }
310
+
311
+ /**
312
+ * Requests permission.
313
+ */
314
+ @objc override public func requestPermissions(_ call: CAPPluginCall) {
315
+ // Fail fast if the host app is missing the required Info.plist usage description key.
316
+ let usageDescription =
317
+ Bundle.main.object(forInfoDictionaryKey: "NSContactsUsageDescription") as? String
318
+ if usageDescription == nil || usageDescription?.isEmpty == true {
319
+ return reject(
320
+ call,
321
+ error: .initFailed(PeopleErrorMessages.missingContactsUsageDescription)
322
+ )
323
+ }
324
+
325
+ let store = CNContactStore()
326
+ store.requestAccess(for: .contacts) { [weak self] granted, error in
327
+ guard let self = self else { return }
328
+ DispatchQueue.main.async {
329
+ if let error = error {
330
+ self.reject(call, error: .initFailed(error.localizedDescription))
331
+ return
332
+ }
333
+ call.resolve(["contacts": granted ? "granted" : "denied"])
334
+ }
335
+ }
336
+ }
337
+
338
+ // MARK: - Contact Fetching
339
+
340
+ /// Retrieves a list of contacts based on provided options.
341
+ @objc func getContacts(_ call: CAPPluginCall) {
342
+ if !ensureContactsPermission(call) { return }
343
+
344
+ let projection = call.getArray("projection", String.self) ?? ["name", "phones", "emails"]
345
+ let limit = call.getInt("limit") ?? 10
346
+ let offset = call.getInt("offset") ?? 0
347
+ let includeTotal = call.getBool("includeTotal") ?? false
348
+
349
+ do {
350
+ // Validation Block
351
+ try PeopleUtils.validateProjection(projection)
352
+
353
+ let result = try implementation.fetchContacts(projection: projection, limit: limit, offset: offset, includeTotal: includeTotal)
354
+ call.resolve([
355
+ "contacts": result.contacts.map { contactToJS($0) },
356
+ "totalCount": result.totalCount
357
+ ])
358
+ } catch { handleError(call, error) }
359
+ }
360
+
361
+ /// Fetch a specific contact by ID
362
+ @objc func getContact(_ call: CAPPluginCall) {
363
+ if !ensureContactsPermission(call) { return }
364
+
365
+ let id = call.getString("id", "")
366
+ if id.isEmpty { return reject(call, error: .invalidInput("id is required")) }
367
+ let projection = call.getArray("projection", String.self) ?? ["name", "phones", "emails"]
368
+
369
+ do {
370
+ // Validation Block
371
+ try PeopleUtils.validateProjection(projection)
372
+
373
+ if let contact = try implementation.fetchContactById(id: id, projection: projection) {
374
+ call.resolve(["contact": contactToJS(contact)])
375
+ } else {
376
+ reject(call, error: .notFound(PeopleErrorMessages.contactNotFound))
377
+ }
378
+ } catch { handleError(call, error) }
379
+ }
380
+
381
+ /// Searches for contacts matching a query string.
382
+ @objc func searchPeople(_ call: CAPPluginCall) {
383
+ if !ensureContactsPermission(call) { return }
384
+
385
+ guard let query = call.getString("query"), !query.isEmpty else {
386
+ return reject(call, error: .invalidInput("Missing query"))
387
+ }
388
+ let projection = call.getArray("projection", String.self) ?? ["name", "phones", "emails"]
389
+ let limit = call.getInt("limit") ?? 50
390
+
391
+ do {
392
+ // Validation Block
393
+ try PeopleUtils.validateProjection(projection)
394
+
395
+ let result = try implementation.searchContacts(query: query, projection: projection, limit: limit)
396
+ call.resolve([
397
+ "contacts": result.contacts.map { contactToJS($0) },
398
+ "totalCount": result.totalCount
399
+ ])
400
+ } catch { handleError(call, error) }
401
+ }
402
+
403
+ // MARK: - Group Management
404
+
405
+ /// Deletes a contact group.
406
+ @objc func listGroups(_ call: CAPPluginCall) {
407
+ if !ensureContactsPermission(call) { return }
408
+
409
+ do {
410
+ let result = try implementation.listGroups()
411
+ call.resolve(["groups": result.groups.map { groupToJS($0) }])
412
+ } catch { handleError(call, error) }
413
+ }
414
+
415
+ /// Creates a new contact group.
416
+ @objc func createGroup(_ call: CAPPluginCall) {
417
+ if !ensureContactsPermission(call) { return }
418
+
419
+ guard let name = call.getString("name"), !name.isEmpty else {
420
+ return reject(call, error: .invalidInput("Group name is required"))
421
+ }
422
+ do {
423
+ let groupData = try implementation.createGroup(name: name)
424
+ call.resolve(["group": groupToJS(groupData)])
425
+ } catch { handleError(call, error) }
426
+ }
427
+
428
+ /// Deletes a group by ID.
429
+ @objc func deleteGroup(_ call: CAPPluginCall) {
430
+ if !ensureContactsPermission(call) { return }
431
+
432
+ guard let groupId = call.getString("groupId") else {
433
+ return reject(call, error: .invalidInput("Group ID is required"))
434
+ }
435
+ do {
436
+ try implementation.deleteGroup(groupId: groupId)
437
+ call.resolve()
438
+ } catch { handleError(call, error) }
439
+ }
440
+
441
+ /// Adds people to a group.
442
+ @objc func addPeopleToGroup(_ call: CAPPluginCall) {
443
+ if !ensureContactsPermission(call) { return }
444
+
445
+ guard let groupId = call.getString("groupId"),
446
+ let contactIds = call.getArray("contactIds", String.self) else {
447
+ return reject(call, error: .invalidInput("Group ID and Contact IDs are required"))
448
+ }
449
+ do {
450
+ try implementation.addPeopleToGroup(groupId: groupId, contactIds: contactIds)
451
+ call.resolve()
452
+ } catch { handleError(call, error) }
453
+ }
454
+
455
+ /// Removes contacts from a group.
456
+ @objc func removePeopleFromGroup(_ call: CAPPluginCall) {
457
+ if !ensureContactsPermission(call) { return }
458
+
459
+ guard let groupId = call.getString("groupId"),
460
+ let contactIds = call.getArray("contactIds", String.self) else {
461
+ return reject(call, error: .invalidInput("Group ID and Contact IDs are required"))
462
+ }
463
+ do {
464
+ try implementation.removePeopleFromGroup(groupId: groupId, contactIds: contactIds)
465
+ call.resolve()
466
+ } catch { handleError(call, error) }
467
+ }
468
+
469
+ // MARK: - CRUD
470
+
471
+ /// Creates a new contact.
472
+ @objc func createContact(_ call: CAPPluginCall) {
473
+ if !ensureContactsPermission(call) { return }
474
+
475
+ guard let contactData = call.getObject("contact") else {
476
+ return reject(call, error: .invalidInput("Contact data is required"))
477
+ }
478
+
479
+ guard hasWritableCreateContactField(contactData) else {
480
+ return reject(call, error: .invalidInput(PeopleErrorMessages.atLeastOneWritableFieldRequired))
481
+ }
482
+ do {
483
+ let newContact = try implementation.createContact(contactData: contactData)
484
+ call.resolve(["contact": contactToJS(newContact)])
485
+ } catch { handleError(call, error) }
486
+ }
487
+
488
+ /// Updates an existing contact.
489
+ @objc func updateContact(_ call: CAPPluginCall) {
490
+ if !ensureContactsPermission(call) { return }
491
+
492
+ guard let contactId = call.getString("contactId"),
493
+ let contactData = call.getObject("contact") else {
494
+ return reject(call, error: .invalidInput("Contact ID and data are required"))
495
+ }
496
+ do {
497
+ let updatedContact = try implementation.updateContact(contactId: contactId, contactData: contactData)
498
+ call.resolve(["contact": contactToJS(updatedContact)])
499
+ } catch { handleError(call, error) }
500
+ }
501
+
502
+ /// Merges two contacts.
503
+ @objc func mergeContacts(_ call: CAPPluginCall) {
504
+ if !ensureContactsPermission(call) { return }
505
+
506
+ guard let sourceId = call.getString("sourceContactId"),
507
+ let destId = call.getString("destinationContactId") else {
508
+ return reject(call, error: .invalidInput("Both source and destination IDs are required"))
509
+ }
510
+ if sourceId == destId {
511
+ return reject(call, error: .invalidInput("sourceContactId and destinationContactId must be different"))
512
+ }
513
+ do {
514
+ let mergedContact = try implementation.mergeContacts(sourceContactId: sourceId, destinationContactId: destId)
515
+ call.resolve(["contact": contactToJS(mergedContact)])
516
+ } catch { handleError(call, error) }
517
+ }
518
+
519
+ /// Deletes a contact.
520
+ @objc func deleteContact(_ call: CAPPluginCall) {
521
+ if !ensureContactsPermission(call) { return }
522
+
523
+ guard let contactId = call.getString("contactId") else {
524
+ return reject(call, error: .invalidInput("Contact ID is required"))
525
+ }
526
+ do {
527
+ try implementation.deleteContact(contactId: contactId)
528
+ call.resolve()
529
+ } catch { handleError(call, error) }
530
+ }
531
+
532
+ // MARK: - Version
533
+
534
+ /**
535
+ * Retrieves the current native plugin version.
536
+ *
537
+ * This version is synchronized from the project's package.json during the build process.
538
+ *
539
+ * - Parameter call: CAPPluginCall used to return the version string.
540
+ */
541
+ @objc func getPluginVersion(_ call: CAPPluginCall) {
542
+ call.resolve(["version": PluginVersion.number])
543
+ }
544
+
545
+ // MARK: - Private Mapping Helpers
546
+
547
+ /// JS marshalling helper for UnifiedContact payloads.
548
+ /// Responsibility: bridge layer only (ContactData → JSObject).
549
+ /// Mapping from CNContact → ContactData is handled in PeopleUtils.
550
+ private func contactToJS(_ contact: ContactData) -> JSObject {
551
+ var res = JSObject()
552
+ res["id"] = contact.id
553
+
554
+ var nameObj = JSObject()
555
+ nameObj["display"] = contact.displayName ?? ""
556
+ if let first = contact.firstName { nameObj["given"] = first }
557
+ if let last = contact.lastName { nameObj["family"] = last }
558
+ res["name"] = nameObj
559
+
560
+ if let org = contact.organization {
561
+ var orgObj = JSObject()
562
+ orgObj["company"] = org.company
563
+ orgObj["title"] = org.title
564
+ orgObj["department"] = org.department
565
+ res["organization"] = orgObj
566
+ }
567
+
568
+ if let birthday = contact.birthday {
569
+ var bday = JSObject()
570
+ bday["day"] = birthday.day
571
+ bday["month"] = birthday.month
572
+ if let year = birthday.year, year != NSDateComponentUndefined { bday["year"] = year }
573
+ res["birthday"] = bday
574
+ }
575
+
576
+ if let phones = contact.phones {
577
+ let mappedPhones: [JSObject] = phones.map { phoneData in
578
+ var phone = JSObject()
579
+ phone["label"] = phoneData.label
580
+ phone["number"] = phoneData.number
581
+ return phone
582
+ }
583
+ res["phones"] = mappedPhones
584
+ }
585
+
586
+ if let emails = contact.emails {
587
+ let mappedEmails: [JSObject] = emails.map { emailData in
588
+ var email = JSObject()
589
+ email["label"] = emailData.label
590
+ email["address"] = emailData.address
591
+ return email
592
+ }
593
+ res["emails"] = mappedEmails
594
+ }
595
+
596
+ if let addresses = contact.addresses {
597
+ let mappedAddresses: [JSObject] = addresses.map { addressData in
598
+ var address = JSObject()
599
+ address["label"] = addressData.label
600
+ address["formatted"] = addressData.formatted
601
+ address["street"] = addressData.street
602
+ address["city"] = addressData.city
603
+ address["region"] = addressData.region
604
+ address["postcode"] = addressData.postcode
605
+ address["country"] = addressData.country
606
+ return address
607
+ }
608
+ res["addresses"] = mappedAddresses
609
+ }
610
+ if let urls = contact.urls { res["urls"] = urls }
611
+ if let note = contact.note { res["note"] = note }
612
+ if let image = contact.image { res["image"] = image }
613
+
614
+ return res
615
+ }
616
+
617
+ /// JS marshalling helper for contact groups.
618
+ /// Responsibility: bridge layer only (GroupData → JSObject).
619
+ private func groupToJS(_ group: GroupData) -> JSObject {
620
+ var res = JSObject()
621
+ res["id"] = group.id
622
+ res["name"] = group.name
623
+ res["source"] = group.source
624
+ res["readOnly"] = group.readOnly
625
+ return res
626
+ }
627
+ }
@@ -0,0 +1,13 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <!--
6
+ This plugin does not access any of Apple's "Required Reason APIs".
7
+ If you add any Required Reason API usage in the future, you must declare it here
8
+ with the appropriate category and reason code(s).
9
+ -->
10
+ <key>NSPrivacyAccessedAPITypes</key>
11
+ <array/>
12
+ </dict>
13
+ </plist>