@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,766 @@
1
+ package io.capkit.people
2
+
3
+ import android.Manifest
4
+ import android.app.Activity
5
+ import android.content.Intent
6
+ import android.net.Uri
7
+ import android.provider.ContactsContract
8
+ import androidx.activity.result.ActivityResult
9
+ import com.getcapacitor.JSArray
10
+ import com.getcapacitor.JSObject
11
+ import com.getcapacitor.PermissionState
12
+ import com.getcapacitor.Plugin
13
+ import com.getcapacitor.PluginCall
14
+ import com.getcapacitor.PluginMethod
15
+ import com.getcapacitor.annotation.ActivityCallback
16
+ import com.getcapacitor.annotation.CapacitorPlugin
17
+ import com.getcapacitor.annotation.Permission
18
+ import com.getcapacitor.annotation.PermissionCallback
19
+ import io.capkit.people.config.PeopleConfig
20
+ import io.capkit.people.error.PeopleError
21
+ import io.capkit.people.error.PeopleErrorMessages
22
+ import io.capkit.people.logger.PeopleLogger
23
+ import io.capkit.people.utils.PeopleUtils
24
+
25
+ /**
26
+ * Capacitor bridge for the People plugin.
27
+ *
28
+ * This class acts as the boundary between JavaScript and native Android code.
29
+ * It handles input parsing, configuration management, and delegates execution
30
+ * to the platform-specific implementation.
31
+ */
32
+ @CapacitorPlugin(
33
+ name = "People",
34
+ permissions = [
35
+ Permission(
36
+ strings = [Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS],
37
+ alias = "contacts",
38
+ ),
39
+ ],
40
+ )
41
+ class PeoplePlugin : Plugin() {
42
+ // -----------------------------------------------------------------------------
43
+ // Properties
44
+ // -----------------------------------------------------------------------------
45
+
46
+ /**
47
+ * Immutable plugin configuration read from capacitor.config.ts.
48
+ * * CONTRACT:
49
+ * - Initialized exactly once in `load()`.
50
+ * - Treated as read-only afterwards.
51
+ */
52
+ private lateinit var config: PeopleConfig
53
+
54
+ /**
55
+ * Native implementation layer containing core Android logic.
56
+ *
57
+ * CONTRACT:
58
+ * - Owned by the Plugin layer.
59
+ * - MUST NOT access PluginCall or Capacitor bridge APIs directly.
60
+ */
61
+ private lateinit var implementation: PeopleImpl
62
+
63
+ /**
64
+ * Observer state for monitoring system-wide contact changes.
65
+ */
66
+ private var observer: PeopleObserver? = null
67
+
68
+ // -----------------------------------------------------------------------------
69
+ // Lifecycle
70
+ // -----------------------------------------------------------------------------
71
+
72
+ /**
73
+ * Called once when the plugin is loaded by the Capacitor bridge.
74
+ *
75
+ * This method initializes the configuration container and the native
76
+ * implementation layer, ensuring all dependencies are injected.
77
+ */
78
+ override fun load() {
79
+ super.load()
80
+
81
+ config = PeopleConfig(this)
82
+ implementation = PeopleImpl(context)
83
+ implementation.updateConfig(config)
84
+
85
+ PeopleLogger.verbose = config.verboseLogging
86
+ PeopleLogger.debug("Plugin loaded")
87
+ }
88
+
89
+ /**
90
+ * Called when the plugin is being destroyed.
91
+ * Ensures all native resources are released.
92
+ */
93
+ override fun handleOnDestroy() {
94
+ cleanupObserver()
95
+ super.handleOnDestroy()
96
+ }
97
+
98
+ // -----------------------------------------------------------------------------
99
+ // Internal Helpers & Denial-Path Handling
100
+ // -----------------------------------------------------------------------------
101
+
102
+ /**
103
+ * Helper to validate 'contacts' permission alias.
104
+ * Returns true if granted, otherwise rejects the call and returns false.
105
+ */
106
+ private fun checkContactsPermission(call: PluginCall): Boolean {
107
+ if (getPermissionState("contacts") != PermissionState.GRANTED) {
108
+ reject(call, PeopleError.PermissionDenied(PeopleErrorMessages.PERMISSION_DENIED))
109
+ return false
110
+ }
111
+ return true
112
+ }
113
+
114
+ /**
115
+ * Rejects the call with a message and a standardized error code.
116
+ * Ensure consistency with the JS PeopleErrorCode enum.
117
+ */
118
+ private fun reject(
119
+ call: PluginCall,
120
+ error: PeopleError,
121
+ ) {
122
+ val code =
123
+ when (error) {
124
+ is PeopleError.Unavailable -> "UNAVAILABLE"
125
+ is PeopleError.Cancelled -> "CANCELLED"
126
+ is PeopleError.PermissionDenied -> "PERMISSION_DENIED"
127
+ is PeopleError.InitFailed -> "INIT_FAILED"
128
+ is PeopleError.InvalidInput -> "INVALID_INPUT"
129
+ is PeopleError.UnknownType -> "UNKNOWN_TYPE"
130
+ is PeopleError.NotFound -> "NOT_FOUND"
131
+ is PeopleError.Conflict -> "CONFLICT"
132
+ is PeopleError.Timeout -> "TIMEOUT"
133
+ }
134
+
135
+ // Always use the message from the PeopleError instance
136
+ val message = error.message ?: "Unknown native error"
137
+ call.reject(message, code)
138
+ }
139
+
140
+ private fun cleanupObserver() {
141
+ observer?.let {
142
+ try {
143
+ it.dispose()
144
+ context.contentResolver.unregisterContentObserver(it)
145
+ } catch (e: Exception) {
146
+ PeopleLogger.error("Error unregistering observer", e)
147
+ }
148
+ observer = null
149
+ }
150
+ }
151
+
152
+ private fun hasWritableCreateContactField(contactData: JSObject): Boolean {
153
+ val name = contactData.optJSONObject("name")
154
+ if (name != null && name.length() > 0) return true
155
+
156
+ val organization = contactData.optJSONObject("organization")
157
+ if (organization != null && organization.length() > 0) return true
158
+
159
+ val birthday = contactData.optJSONObject("birthday")
160
+ if (birthday != null && birthday.length() > 0) return true
161
+
162
+ val phones = contactData.optJSONArray("phones")
163
+ if (phones != null && phones.length() > 0) return true
164
+
165
+ val emails = contactData.optJSONArray("emails")
166
+ if (emails != null && emails.length() > 0) return true
167
+
168
+ val addresses = contactData.optJSONArray("addresses")
169
+ if (addresses != null && addresses.length() > 0) return true
170
+
171
+ val urls = contactData.optJSONArray("urls")
172
+ if (urls != null && urls.length() > 0) return true
173
+
174
+ if (contactData.optString("note", "").isNotBlank()) return true
175
+ if (contactData.optString("image", "").isNotBlank()) return true
176
+
177
+ return false
178
+ }
179
+
180
+ // -----------------------------------------------------------------------------
181
+ // Zero-Permission Picker
182
+ // -----------------------------------------------------------------------------
183
+
184
+ /**
185
+ * Launches the native contact picker (Zero-Permission).
186
+ * This allows the user to select a single contact without granting full address book access.
187
+ */
188
+ @PluginMethod
189
+ fun pickContact(call: PluginCall) {
190
+ // 1. Prepare the intent
191
+ val intent = Intent(Intent.ACTION_PICK, ContactsContract.Contacts.CONTENT_URI)
192
+
193
+ // Persist the call before launching an external Activity (Capacitor v8 rule).
194
+ bridge.saveCall(call)
195
+
196
+ // 2. Start Activity using Capacitor Bridge
197
+ // Note: We use a specific callback name defined below
198
+ startActivityForResult(call, intent, "handlePickContactResult")
199
+ }
200
+
201
+ /**
202
+ * Processes the result from the system Contact Picker activity.
203
+ */
204
+ @ActivityCallback
205
+ private fun handlePickContactResult(
206
+ call: PluginCall,
207
+ result: ActivityResult,
208
+ ) {
209
+ // Retrieve the persisted call (Capacitor v8 rule).
210
+ val savedCall = bridge.getSavedCall(call.callbackId) ?: call
211
+ try {
212
+ if (result.resultCode == Activity.RESULT_OK && result.data != null) {
213
+ val contactUri: Uri? = result.data?.data
214
+ if (contactUri != null) {
215
+ try {
216
+ val projectionArray = savedCall.getArray("projection")
217
+ val projection =
218
+ if (projectionArray != null && projectionArray.length() > 0) {
219
+ List(projectionArray.length()) { i -> projectionArray.getString(i) }
220
+ } else {
221
+ // Default projection: basic contact info
222
+ listOf("name", "phones", "emails")
223
+ }
224
+
225
+ val contact = implementation.getContactFromUri(contactUri, projection)
226
+ if (contact != null) {
227
+ val ret = JSObject()
228
+ ret.put("contact", contactToJS(contact))
229
+ savedCall.resolve(ret)
230
+ } else {
231
+ reject(savedCall, PeopleError.NotFound(PeopleErrorMessages.CONTACT_NOT_FOUND))
232
+ }
233
+ } catch (e: Exception) {
234
+ PeopleLogger.error("Error processing picked contact", e)
235
+ reject(savedCall, PeopleError.InitFailed(PeopleErrorMessages.ERROR_PROCESSING_CONTACT))
236
+ }
237
+ } else {
238
+ reject(savedCall, PeopleError.InitFailed(PeopleErrorMessages.NO_CONTACT_URI_RETURNED))
239
+ }
240
+ } else {
241
+ // User cancellation should be mapped explicitly.
242
+ reject(savedCall, PeopleError.Cancelled(PeopleErrorMessages.USER_CANCELLED_SELECTION))
243
+ }
244
+ } finally {
245
+ bridge.releaseCall(savedCall)
246
+ }
247
+ }
248
+
249
+ // -----------------------------------------------------------------------------
250
+ // Event Listeners
251
+ // -----------------------------------------------------------------------------
252
+
253
+ @PluginMethod
254
+ override fun addListener(call: PluginCall) {
255
+ val eventName = call.getString("eventName")
256
+
257
+ if (eventName.isNullOrBlank()) {
258
+ return reject(call, PeopleError.InvalidInput(PeopleErrorMessages.EVENT_NAME_REQUIRED))
259
+ }
260
+
261
+ if (eventName != "peopleChange") {
262
+ return reject(call, PeopleError.InvalidInput(PeopleErrorMessages.unsupportedEventName(eventName)))
263
+ }
264
+
265
+ if (!checkContactsPermission(call)) return
266
+
267
+ call.setKeepAlive(true)
268
+ super.addListener(call)
269
+
270
+ if (observer == null) {
271
+ observer =
272
+ PeopleObserver { _, ids ->
273
+ // Ensure a consistent payload for "peopleChange" listeners.
274
+ // Always include "ids" as an array (may be empty) and a "type" string with default "update".
275
+ val payload = JSObject()
276
+ val changedIds: List<String> = ids ?: emptyList()
277
+ val idsArray = JSArray()
278
+ for (id in changedIds) {
279
+ idsArray.put(id)
280
+ }
281
+ payload.put("ids", idsArray)
282
+
283
+ payload.put("type", "update")
284
+
285
+ if (hasListeners("peopleChange")) {
286
+ notifyListeners("peopleChange", payload)
287
+ }
288
+ }
289
+
290
+ context.contentResolver.registerContentObserver(PeopleObserver.CONTACTS_URI, true, observer!!)
291
+ }
292
+ }
293
+
294
+ @PluginMethod
295
+ override fun removeListener(call: PluginCall) {
296
+ super.removeListener(call)
297
+ if (!hasListeners("peopleChange")) {
298
+ cleanupObserver()
299
+ }
300
+ }
301
+
302
+ @PluginMethod
303
+ override fun removeAllListeners(call: PluginCall) {
304
+ cleanupObserver()
305
+ super.removeAllListeners(call)
306
+ }
307
+
308
+ // -----------------------------------------------------------------------------
309
+ // Systemic Access
310
+ // -----------------------------------------------------------------------------
311
+
312
+ /**
313
+ * Retrieves a list of contacts from the device address book.
314
+ * Requires the 'contacts' permission to be granted.
315
+ *
316
+ * @param call PluginCall containing 'limit', 'offset', and 'projection'.
317
+ */
318
+ @PluginMethod
319
+ fun getContacts(call: PluginCall) {
320
+ if (!checkContactsPermission(call)) return
321
+
322
+ // Ensure a safe default projection if none is provided to avoid empty results
323
+ val projectionArray = call.getArray("projection")
324
+ val projection =
325
+ if (projectionArray != null && projectionArray.length() > 0) {
326
+ List(projectionArray.length()) { i -> projectionArray.getString(i) }
327
+ } else {
328
+ // Default projection: basic contact info
329
+ listOf("name", "phones", "emails")
330
+ }
331
+
332
+ try {
333
+ PeopleUtils.validateProjection(projection)
334
+ } catch (e: PeopleError) {
335
+ return reject(call, e)
336
+ }
337
+
338
+ val limit = call.getInt("limit") ?: 50
339
+ val offset = call.getInt("offset") ?: 0
340
+
341
+ // Execute implementation logic (runs on plugin background thread)
342
+ val (contacts, total) =
343
+ implementation.getContacts(
344
+ projection,
345
+ limit,
346
+ offset,
347
+ )
348
+
349
+ val ret = JSObject()
350
+ ret.put("contacts", contactsToJSArray(contacts))
351
+ ret.put("totalCount", total)
352
+
353
+ call.resolve(ret)
354
+ }
355
+
356
+ /**
357
+ * Retrieves a single contact by its ID.
358
+ * Requires the 'contacts' permission to be granted.
359
+ *
360
+ * @param call PluginCall containing the 'id' of the contact.
361
+ */
362
+ @PluginMethod
363
+ fun getContact(call: PluginCall) {
364
+ if (!checkContactsPermission(call)) return
365
+
366
+ val id = call.getString("id") ?: return reject(call, PeopleError.InvalidInput(PeopleErrorMessages.ID_REQUIRED))
367
+
368
+ // Apply safe default projection for single contact retrieval
369
+ val projectionArray = call.getArray("projection")
370
+ val projection =
371
+ if (projectionArray != null && projectionArray.length() > 0) {
372
+ List(projectionArray.length()) { i -> projectionArray.getString(i) }
373
+ } else {
374
+ // Default projection: basic contact info
375
+ listOf("name", "phones", "emails")
376
+ }
377
+
378
+ try {
379
+ PeopleUtils.validateProjection(projection)
380
+ } catch (e: PeopleError) {
381
+ return reject(call, e)
382
+ }
383
+
384
+ val contact = implementation.getContactById(id, projection)
385
+ if (contact == null) return reject(call, PeopleError.NotFound(PeopleErrorMessages.CONTACT_NOT_FOUND))
386
+
387
+ val ret = JSObject()
388
+ ret.put("contact", contactToJS(contact))
389
+ call.resolve(ret)
390
+ }
391
+
392
+ /**
393
+ * Searches for contacts based on a query string.
394
+ * Requires the 'contacts' permission to be granted.
395
+ *
396
+ * @param call PluginCall containing 'query', 'projection', and 'limit'.
397
+ */
398
+ @PluginMethod
399
+ fun searchPeople(call: PluginCall) {
400
+ if (!checkContactsPermission(call)) return
401
+
402
+ val query =
403
+ call.getString("query") ?: return reject(call, PeopleError.InvalidInput(PeopleErrorMessages.QUERY_REQUIRED))
404
+ // Apply safe default projection for search results
405
+ val projectionArray = call.getArray("projection")
406
+ val projection =
407
+ if (projectionArray != null && projectionArray.length() > 0) {
408
+ List(projectionArray.length()) { i -> projectionArray.getString(i) }
409
+ } else {
410
+ // Default projection: basic contact info
411
+ listOf("name", "phones", "emails")
412
+ }
413
+
414
+ try {
415
+ PeopleUtils.validateProjection(projection)
416
+ } catch (e: PeopleError) {
417
+ return reject(call, e)
418
+ }
419
+
420
+ val limit = call.getInt("limit") ?: 50
421
+
422
+ // Execute implementation logic
423
+ val (contacts, total) =
424
+ implementation.searchContacts(
425
+ query,
426
+ projection,
427
+ limit,
428
+ )
429
+
430
+ val ret = JSObject()
431
+ ret.put("contacts", contactsToJSArray(contacts))
432
+ ret.put("totalCount", total)
433
+
434
+ call.resolve(ret)
435
+ }
436
+
437
+ // -----------------------------------------------------------------------------
438
+ // CRUD & Group Management (Denied-path protected)
439
+ // -----------------------------------------------------------------------------
440
+
441
+ @PluginMethod
442
+ fun listGroups(call: PluginCall) {
443
+ if (!checkContactsPermission(call)) return
444
+
445
+ try {
446
+ val groups = implementation.listGroups()
447
+ val groupsArray = JSArray()
448
+ for (group in groups) {
449
+ groupsArray.put(groupToJS(group))
450
+ }
451
+ val ret = JSObject()
452
+ ret.put("groups", groupsArray)
453
+ call.resolve(ret)
454
+ } catch (e: PeopleError) {
455
+ reject(call, e)
456
+ }
457
+ }
458
+
459
+ @PluginMethod
460
+ fun createGroup(call: PluginCall) {
461
+ if (!checkContactsPermission(call)) return
462
+
463
+ val name =
464
+ call.getString("name") ?: return reject(call, PeopleError.InvalidInput(PeopleErrorMessages.GROUP_NAME_REQUIRED))
465
+
466
+ try {
467
+ val group = implementation.createGroup(name)
468
+ val ret = JSObject()
469
+ ret.put("group", groupToJS(group))
470
+ call.resolve(ret)
471
+ } catch (e: PeopleError) {
472
+ reject(call, e)
473
+ }
474
+ }
475
+
476
+ @PluginMethod
477
+ fun deleteGroup(call: PluginCall) {
478
+ if (!checkContactsPermission(call)) return
479
+
480
+ val groupId =
481
+ call.getString("groupId") ?: return reject(call, PeopleError.InvalidInput(PeopleErrorMessages.GROUP_ID_REQUIRED))
482
+
483
+ try {
484
+ implementation.deleteGroup(groupId)
485
+ call.resolve()
486
+ } catch (e: PeopleError) {
487
+ reject(call, e)
488
+ }
489
+ }
490
+
491
+ @PluginMethod
492
+ fun addPeopleToGroup(call: PluginCall) {
493
+ if (!checkContactsPermission(call)) return
494
+
495
+ val groupId =
496
+ call.getString("groupId") ?: return reject(call, PeopleError.InvalidInput(PeopleErrorMessages.GROUP_ID_REQUIRED))
497
+ val contactIdsArray =
498
+ call.getArray("contactIds")
499
+ ?: return reject(call, PeopleError.InvalidInput(PeopleErrorMessages.CONTACT_IDS_REQUIRED))
500
+
501
+ val contactIds = List(contactIdsArray.length()) { i -> contactIdsArray.getString(i) }
502
+
503
+ try {
504
+ implementation.addPeopleToGroup(groupId, contactIds)
505
+ call.resolve()
506
+ } catch (e: PeopleError) {
507
+ reject(call, e)
508
+ }
509
+ }
510
+
511
+ @PluginMethod
512
+ fun removePeopleFromGroup(call: PluginCall) {
513
+ if (!checkContactsPermission(call)) return
514
+
515
+ val groupId =
516
+ call.getString("groupId") ?: return reject(call, PeopleError.InvalidInput(PeopleErrorMessages.GROUP_ID_REQUIRED))
517
+ val contactIdsArray =
518
+ call.getArray("contactIds")
519
+ ?: return reject(call, PeopleError.InvalidInput(PeopleErrorMessages.CONTACT_IDS_REQUIRED))
520
+
521
+ val contactIds = List(contactIdsArray.length()) { i -> contactIdsArray.getString(i) }
522
+
523
+ try {
524
+ implementation.removePeopleFromGroup(groupId, contactIds)
525
+ call.resolve()
526
+ } catch (e: PeopleError) {
527
+ reject(call, e)
528
+ }
529
+ }
530
+
531
+ @PluginMethod
532
+ fun createContact(call: PluginCall) {
533
+ if (!checkContactsPermission(call)) return
534
+
535
+ val contactJS =
536
+ call.getObject("contact")
537
+ ?: return reject(call, PeopleError.InvalidInput(PeopleErrorMessages.CONTACT_DATA_REQUIRED))
538
+ if (!hasWritableCreateContactField(contactJS)) {
539
+ return reject(call, PeopleError.InvalidInput(PeopleErrorMessages.AT_LEAST_ONE_WRITABLE_FIELD_REQUIRED))
540
+ }
541
+
542
+ // Marshalling: Safe data extraction using Capacitor v8 typed getters
543
+ val nameObj = contactJS.getJSObject("name")
544
+ val givenName = nameObj?.getString("given")
545
+ val familyName = nameObj?.getString("family")
546
+
547
+ // Marshalling: Extract JSONArrays using the standard getJSONArray method
548
+ val phones = PeopleUtils.extractStringList(contactJS.getJSONArray("phones"), "number")
549
+
550
+ // Delegate extraction to updated Utils helper
551
+ val emails = PeopleUtils.extractStringList(contactJS.getJSONArray("emails"), "address")
552
+
553
+ try {
554
+ val contact = implementation.createContact(givenName, familyName, phones, emails)
555
+ val ret = JSObject()
556
+ // Use Mapper to return JSObject to the Bridge
557
+ ret.put("contact", contactToJS(contact))
558
+ call.resolve(ret)
559
+ } catch (e: PeopleError) {
560
+ reject(call, e)
561
+ }
562
+ }
563
+
564
+ @PluginMethod
565
+ fun updateContact(call: PluginCall) {
566
+ if (!checkContactsPermission(call)) return
567
+
568
+ val contactId = call.getString("contactId")
569
+ val contactData = call.getObject("contact")
570
+
571
+ if (contactId.isNullOrEmpty() ||
572
+ contactData == null
573
+ ) {
574
+ return reject(call, PeopleError.InvalidInput(PeopleErrorMessages.REQUIRED_FIELDS_MISSING))
575
+ }
576
+
577
+ // Marshalling: Extract only what the native Impl needs
578
+ val nameObj = contactData.getJSObject("name")
579
+
580
+ // Delegate to Impl using primitive types
581
+ try {
582
+ val updatedContact =
583
+ implementation.updateContact(
584
+ contactId,
585
+ nameObj?.getString("given"),
586
+ nameObj?.getString("family"),
587
+ )
588
+ val ret = JSObject()
589
+ // Map native model back to JSObject via Utils
590
+ ret.put("contact", contactToJS(updatedContact))
591
+ call.resolve(ret)
592
+ } catch (e: PeopleError) {
593
+ reject(call, e)
594
+ }
595
+ }
596
+
597
+ @PluginMethod
598
+ fun deleteContact(call: PluginCall) {
599
+ if (!checkContactsPermission(call)) return
600
+
601
+ val contactId =
602
+ call.getString("contactId")
603
+ ?: return reject(call, PeopleError.InvalidInput(PeopleErrorMessages.CONTACT_ID_REQUIRED))
604
+
605
+ try {
606
+ implementation.deleteContact(contactId)
607
+ call.resolve()
608
+ } catch (e: PeopleError) {
609
+ reject(call, e)
610
+ }
611
+ }
612
+
613
+ @PluginMethod
614
+ fun mergeContacts(call: PluginCall) {
615
+ if (!checkContactsPermission(call)) return
616
+
617
+ val sourceId = call.getString("sourceContactId")
618
+ val destId = call.getString("destinationContactId")
619
+
620
+ if (sourceId.isNullOrEmpty() ||
621
+ destId.isNullOrEmpty()
622
+ ) {
623
+ return reject(call, PeopleError.InvalidInput(PeopleErrorMessages.IDS_REQUIRED))
624
+ }
625
+ if (sourceId == destId) {
626
+ return reject(
627
+ call,
628
+ PeopleError.InvalidInput("sourceContactId and destinationContactId must be different"),
629
+ )
630
+ }
631
+ try {
632
+ val mergedContact = implementation.mergeContacts(sourceId, destId)
633
+ val ret = JSObject()
634
+ ret.put("contact", contactToJS(mergedContact))
635
+ call.resolve(ret)
636
+ } catch (e: PeopleError) {
637
+ reject(call, e)
638
+ }
639
+ }
640
+
641
+ @PluginMethod
642
+ override fun requestPermissions(call: PluginCall) {
643
+ if (getPermissionState("contacts") == PermissionState.GRANTED) {
644
+ checkPermissions(call)
645
+ return
646
+ }
647
+
648
+ // The callback must be a method annotated with @PermissionCallback
649
+ requestPermissionForAlias("contacts", call, "contactsPermissionsCallback")
650
+ }
651
+
652
+ @PermissionCallback
653
+ private fun contactsPermissionsCallback(call: PluginCall) {
654
+ // After the permission prompt, return the updated permission states
655
+ checkPermissions(call)
656
+ }
657
+
658
+ @PluginMethod
659
+ fun getCapabilities(call: PluginCall) {
660
+ val caps = implementation.getCapabilities(getPermissionState("contacts") == PermissionState.GRANTED)
661
+ call.resolve(JSObject.fromJSONObject(org.json.JSONObject(caps)))
662
+ }
663
+
664
+ // -----------------------------------------------------------------------------
665
+ // Version Information
666
+ // -----------------------------------------------------------------------------
667
+
668
+ /**
669
+ * Returns the native plugin version synchronized from package.json.
670
+ *
671
+ * This information is used for diagnostics and ensuring parity between
672
+ * the JavaScript and native layers.
673
+ *
674
+ * @param call The bridge call to resolve with version data.
675
+ */
676
+ @PluginMethod
677
+ fun getPluginVersion(call: PluginCall) {
678
+ val ret = JSObject()
679
+ ret.put("version", BuildConfig.PLUGIN_VERSION)
680
+ call.resolve(ret)
681
+ }
682
+
683
+ // -----------------------------------------------------------------------------
684
+ // Bridge Marshalling (Native -> JS)
685
+ // -----------------------------------------------------------------------------
686
+
687
+ // Add these private helpers at the end of PeoplePlugin class
688
+ private fun contactToJS(contact: io.capkit.people.models.ContactData): JSObject {
689
+ val js = JSObject()
690
+ js.put("id", contact.id)
691
+
692
+ contact.displayName?.let {
693
+ val nameObj = JSObject()
694
+ nameObj.put("display", it)
695
+ js.put("name", nameObj)
696
+ }
697
+
698
+ if (contact.phones.isNotEmpty()) {
699
+ val phonesArr = JSArray()
700
+ for (phone in contact.phones) {
701
+ val p = JSObject()
702
+ p.put("number", phone.value)
703
+ p.put("label", phone.label)
704
+ phonesArr.put(p)
705
+ }
706
+ js.put("phones", phonesArr)
707
+ }
708
+
709
+ if (contact.emails.isNotEmpty()) {
710
+ val emailsArr = JSArray()
711
+ for (email in contact.emails) {
712
+ val e = JSObject()
713
+ e.put("address", email.value)
714
+ e.put("label", email.label)
715
+ emailsArr.put(e)
716
+ }
717
+ js.put("emails", emailsArr)
718
+ }
719
+
720
+ contact.organization?.let { org ->
721
+ val orgObj = JSObject()
722
+ orgObj.put("company", org.company)
723
+ orgObj.put("title", org.title)
724
+ orgObj.put("department", org.department)
725
+ js.put("organization", orgObj)
726
+ }
727
+
728
+ if (contact.addresses.isNotEmpty()) {
729
+ val addrArr = JSArray()
730
+ for (addr in contact.addresses) {
731
+ val a = JSObject()
732
+ a.put("label", addr.label)
733
+ a.put("street", addr.street)
734
+ a.put("city", addr.city)
735
+ a.put("region", addr.region)
736
+ a.put("postcode", addr.postcode)
737
+ a.put("country", addr.country)
738
+ addrArr.put(a)
739
+ }
740
+ js.put("addresses", addrArr)
741
+ }
742
+ return js
743
+ }
744
+
745
+ private fun groupToJS(group: io.capkit.people.models.GroupData): JSObject {
746
+ val js = JSObject()
747
+ js.put("id", group.id)
748
+ js.put("name", group.name)
749
+ js.put("source", group.source)
750
+ js.put("readOnly", group.readOnly)
751
+ return js
752
+ }
753
+
754
+ /**
755
+ * JS marshalling helper for UnifiedContact payloads.
756
+ * Responsibility: bridge layer only (ContactData → JSObject / JSArray).
757
+ * Native contact mapping from platform types → ContactData is handled in the Impl/Utils layer.
758
+ */
759
+ private fun contactsToJSArray(contacts: List<io.capkit.people.models.ContactData>): JSArray {
760
+ val arr = JSArray()
761
+ for (contact in contacts) {
762
+ arr.put(contactToJS(contact))
763
+ }
764
+ return arr
765
+ }
766
+ }