@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,1003 @@
1
+ package io.capkit.people
2
+
3
+ import android.content.ContentValues
4
+ import android.content.Context
5
+ import android.net.Uri
6
+ import android.provider.ContactsContract
7
+ import io.capkit.people.config.PeopleConfig
8
+ import io.capkit.people.error.PeopleError
9
+ import io.capkit.people.error.PeopleErrorMessages
10
+ import io.capkit.people.logger.PeopleLogger
11
+ import io.capkit.people.models.ContactData
12
+ import io.capkit.people.models.ContactField
13
+ import io.capkit.people.models.ContactOrganization
14
+ import io.capkit.people.models.GroupData
15
+ import io.capkit.people.models.PostalAddress
16
+ import io.capkit.people.utils.PeopleUtils
17
+ import io.capkit.people.utils.PeopleUtils.getInt
18
+ import io.capkit.people.utils.PeopleUtils.getString
19
+
20
+ /**
21
+ * Native implementation for the People plugin.
22
+ *
23
+ * Architectural rules:
24
+ * - MUST NOT reference Capacitor APIs (JSObject, PluginCall, etc.).
25
+ * - Returns only native models (ContactData) or standard Kotlin types.
26
+ * - Logic is executed in the background thread provided by the bridge.
27
+ */
28
+ class PeopleImpl(
29
+ private val context: Context,
30
+ ) {
31
+ // -----------------------------------------------------------------------------
32
+ // Properties
33
+ // -----------------------------------------------------------------------------
34
+
35
+ /**
36
+ * Cached plugin configuration container.
37
+ * Provided once during initialization via [updateConfig].
38
+ */
39
+ private lateinit var config: PeopleConfig
40
+
41
+ // -----------------------------------------------------------------------------
42
+ // Companion Object
43
+ // -----------------------------------------------------------------------------
44
+
45
+ private companion object {
46
+ /**
47
+ * Account type identifier for internal plugin identification.
48
+ */
49
+ const val ACCOUNT_TYPE = "io.capkit.people"
50
+
51
+ /**
52
+ * Human-readable account name for the plugin.
53
+ */
54
+ const val ACCOUNT_NAME = "People"
55
+
56
+ /**
57
+ * Chunk size for bulk Data queries to avoid very large IN clauses and memory spikes.
58
+ */
59
+ const val BULK_CONTACT_BATCH_SIZE = 200
60
+ }
61
+
62
+ // -----------------------------------------------------------------------------
63
+ // Configuration & Capabilities
64
+ // -----------------------------------------------------------------------------
65
+
66
+ /**
67
+ * Applies the plugin configuration to the implementation layer.
68
+ */
69
+ fun updateConfig(newConfig: PeopleConfig) {
70
+ this.config = newConfig
71
+ PeopleLogger.verbose = newConfig.verboseLogging
72
+ PeopleLogger.debug(
73
+ "Configuration applied. Verbose logging:",
74
+ newConfig.verboseLogging.toString(),
75
+ )
76
+ }
77
+
78
+ /**
79
+ * Returns capabilities as a native Map.
80
+ * Linked to runtime permission state for consistency.
81
+ */
82
+ fun getCapabilities(hasPermission: Boolean): Map<String, Boolean> =
83
+ mapOf(
84
+ "canRead" to hasPermission,
85
+ "canWrite" to hasPermission,
86
+ "canObserve" to hasPermission, // Updated: cannot observe without permissions
87
+ "canManageGroups" to hasPermission,
88
+ "canPickContact" to true,
89
+ )
90
+
91
+ // -----------------------------------------------------------------------------
92
+ // Core Engine (Projection & Mapping)
93
+ // -----------------------------------------------------------------------------
94
+
95
+ /**
96
+ * Internal Core Engine: Queries ContactsContract.Data and populates a ContactData object.
97
+ * This is the heart of the "Projection Engine" to minimize memory usage.
98
+ */
99
+ private fun fillContactData(
100
+ contactId: String,
101
+ baseContact: ContactData,
102
+ projection: List<String>,
103
+ ): ContactData {
104
+ val contentResolver = context.contentResolver
105
+ val phones = mutableListOf<ContactField>()
106
+ val emails = mutableListOf<ContactField>()
107
+ var displayName: String? = baseContact.displayName
108
+ var organization: ContactOrganization? = null
109
+ val addresses = mutableListOf<PostalAddress>()
110
+
111
+ val selection = "${ContactsContract.Data.CONTACT_ID} = ?"
112
+ val selectionArgs = arrayOf(contactId)
113
+ val sortOrder = ContactsContract.Data.MIMETYPE
114
+
115
+ val cursor =
116
+ contentResolver.query(
117
+ ContactsContract.Data.CONTENT_URI,
118
+ null,
119
+ selection,
120
+ selectionArgs,
121
+ sortOrder,
122
+ )
123
+
124
+ cursor?.use { c ->
125
+ while (c.moveToNext()) {
126
+ val mimeType = c.getString(ContactsContract.Data.MIMETYPE)
127
+
128
+ // Only process data rows that match the requested projection fields
129
+ when (mimeType) {
130
+ PeopleUtils.MIME_NAME -> {
131
+ if (projection.contains("name")) {
132
+ displayName = c.getString(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME)
133
+ }
134
+ }
135
+ PeopleUtils.MIME_PHONE -> {
136
+ if (projection.contains("phones")) {
137
+ val number = c.getString(ContactsContract.CommonDataKinds.Phone.NUMBER)
138
+ val type = c.getInt(ContactsContract.CommonDataKinds.Phone.TYPE) ?: 0
139
+ val rawLabel =
140
+ ContactsContract.CommonDataKinds.Phone
141
+ .getTypeLabel(context.resources, type, "")
142
+ .toString()
143
+ val label = PeopleUtils.normalizeLabel(rawLabel)
144
+ if (number != null) phones.add(ContactField(label, number))
145
+ }
146
+ }
147
+ PeopleUtils.MIME_EMAIL -> {
148
+ if (projection.contains("emails")) {
149
+ val address = c.getString(ContactsContract.CommonDataKinds.Email.ADDRESS)
150
+ val type = c.getInt(ContactsContract.CommonDataKinds.Email.TYPE) ?: 0
151
+ val label =
152
+ PeopleUtils.normalizeLabel(
153
+ ContactsContract.CommonDataKinds.Email
154
+ .getTypeLabel(context.resources, type, "")
155
+ .toString(),
156
+ )
157
+ if (address != null) emails.add(ContactField(label, address))
158
+ }
159
+ }
160
+ PeopleUtils.MIME_ORG -> {
161
+ if (projection.contains("organization")) {
162
+ organization =
163
+ ContactOrganization(
164
+ company = c.getString(ContactsContract.CommonDataKinds.Organization.COMPANY),
165
+ title = c.getString(ContactsContract.CommonDataKinds.Organization.TITLE),
166
+ department = c.getString(ContactsContract.CommonDataKinds.Organization.DEPARTMENT),
167
+ )
168
+ }
169
+ }
170
+ PeopleUtils.MIME_ADDRESS -> {
171
+ if (projection.contains("addresses")) {
172
+ val type = c.getInt(ContactsContract.CommonDataKinds.StructuredPostal.TYPE) ?: 0
173
+ val label =
174
+ PeopleUtils.normalizeLabel(
175
+ ContactsContract.CommonDataKinds.StructuredPostal
176
+ .getTypeLabel(context.resources, type, "")
177
+ .toString(),
178
+ )
179
+
180
+ addresses.add(
181
+ PostalAddress(
182
+ label = label,
183
+ street = c.getString(ContactsContract.CommonDataKinds.StructuredPostal.STREET),
184
+ city = c.getString(ContactsContract.CommonDataKinds.StructuredPostal.CITY),
185
+ region = c.getString(ContactsContract.CommonDataKinds.StructuredPostal.REGION),
186
+ postcode = c.getString(ContactsContract.CommonDataKinds.StructuredPostal.POSTCODE),
187
+ country = c.getString(ContactsContract.CommonDataKinds.StructuredPostal.COUNTRY),
188
+ ),
189
+ )
190
+ }
191
+ }
192
+ // Optimization: Load image data (thumbnail) only if explicitly requested
193
+ ContactsContract.CommonDataKinds.Photo.CONTENT_ITEM_TYPE -> {
194
+ if (projection.contains("image")) {
195
+ // Lazy loading for images would be implemented here
196
+ PeopleLogger.debug("Image data detected for contact $contactId")
197
+ }
198
+ }
199
+ }
200
+ }
201
+ }
202
+
203
+ // Return a new immutable copy of the data
204
+ return baseContact.copy(
205
+ displayName = displayName,
206
+ organization = organization,
207
+ phones = phones,
208
+ emails = emails,
209
+ addresses = addresses,
210
+ )
211
+ }
212
+
213
+ private data class ContactAccum(
214
+ var displayName: String? = null,
215
+ var organization: ContactOrganization? = null,
216
+ val phones: MutableList<ContactField> = mutableListOf(),
217
+ val emails: MutableList<ContactField> = mutableListOf(),
218
+ val addresses: MutableList<PostalAddress> = mutableListOf(),
219
+ )
220
+
221
+ /**
222
+ * Bulk version of the projection engine.
223
+ * It performs a single ContactsContract.Data query for a page of contacts.
224
+ *
225
+ * IMPORTANT: This function performs no side effects and does not touch PluginCall.
226
+ */
227
+ private fun fillContactsDataBulk(
228
+ baseContacts: List<ContactData>,
229
+ projection: List<String>,
230
+ ): List<ContactData> {
231
+ if (baseContacts.isEmpty()) return emptyList()
232
+
233
+ val resolver = context.contentResolver
234
+ val includeName = projection.contains("name")
235
+ val includePhones = projection.contains("phones")
236
+ val includeEmails = projection.contains("emails")
237
+ val includeOrganization = projection.contains("organization")
238
+ val includeAddresses = projection.contains("addresses")
239
+ val includeImage = projection.contains("image")
240
+
241
+ val ids = baseContacts.map { it.id }
242
+ val sortOrder = "${ContactsContract.Data.CONTACT_ID} ASC, ${ContactsContract.Data.MIMETYPE} ASC"
243
+
244
+ // Pre-size maps/lists to reduce allocations on large datasets.
245
+ val acc = HashMap<String, ContactAccum>(ids.size * 2)
246
+ for (id in ids) {
247
+ acc[id] =
248
+ ContactAccum(
249
+ phones = ArrayList(2),
250
+ emails = ArrayList(2),
251
+ addresses = ArrayList(1),
252
+ )
253
+ }
254
+
255
+ // Build a minimal column projection to avoid fetching unnecessary data.
256
+ val cols = LinkedHashSet<String>(16)
257
+ cols.add(ContactsContract.Data.CONTACT_ID)
258
+ cols.add(ContactsContract.Data.MIMETYPE)
259
+
260
+ if (includeName) {
261
+ cols.add(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME)
262
+ }
263
+
264
+ if (includePhones) {
265
+ cols.add(ContactsContract.CommonDataKinds.Phone.NUMBER)
266
+ cols.add(ContactsContract.CommonDataKinds.Phone.TYPE)
267
+ }
268
+
269
+ if (includeEmails) {
270
+ cols.add(ContactsContract.CommonDataKinds.Email.ADDRESS)
271
+ cols.add(ContactsContract.CommonDataKinds.Email.TYPE)
272
+ }
273
+
274
+ if (includeOrganization) {
275
+ cols.add(ContactsContract.CommonDataKinds.Organization.COMPANY)
276
+ cols.add(ContactsContract.CommonDataKinds.Organization.TITLE)
277
+ cols.add(ContactsContract.CommonDataKinds.Organization.DEPARTMENT)
278
+ }
279
+
280
+ if (includeAddresses) {
281
+ cols.add(ContactsContract.CommonDataKinds.StructuredPostal.TYPE)
282
+ cols.add(ContactsContract.CommonDataKinds.StructuredPostal.STREET)
283
+ cols.add(ContactsContract.CommonDataKinds.StructuredPostal.CITY)
284
+ cols.add(ContactsContract.CommonDataKinds.StructuredPostal.REGION)
285
+ cols.add(ContactsContract.CommonDataKinds.StructuredPostal.POSTCODE)
286
+ cols.add(ContactsContract.CommonDataKinds.StructuredPostal.COUNTRY)
287
+ }
288
+
289
+ // Note: "image" is currently only detected (no blob read), so no extra columns needed.
290
+ val dataColumns = cols.toTypedArray()
291
+ val requestedMimeTypes = mutableListOf<String>()
292
+ if (includeName) requestedMimeTypes.add(PeopleUtils.MIME_NAME)
293
+ if (includePhones) requestedMimeTypes.add(PeopleUtils.MIME_PHONE)
294
+ if (includeEmails) requestedMimeTypes.add(PeopleUtils.MIME_EMAIL)
295
+ if (includeOrganization) requestedMimeTypes.add(PeopleUtils.MIME_ORG)
296
+ if (includeAddresses) requestedMimeTypes.add(PeopleUtils.MIME_ADDRESS)
297
+ if (includeImage) requestedMimeTypes.add(ContactsContract.CommonDataKinds.Photo.CONTENT_ITEM_TYPE)
298
+
299
+ // Query in fixed-size chunks to cap cursor and in-memory accumulation pressure on large pages.
300
+ for (idChunk in ids.chunked(BULK_CONTACT_BATCH_SIZE)) {
301
+ val idPlaceholders = idChunk.joinToString(",") { "?" }
302
+ val selectionBuilder = StringBuilder("${ContactsContract.Data.CONTACT_ID} IN ($idPlaceholders)")
303
+ val selectionArgs = mutableListOf<String>().apply { addAll(idChunk) }
304
+
305
+ if (requestedMimeTypes.isNotEmpty()) {
306
+ val mimePlaceholders = requestedMimeTypes.joinToString(",") { "?" }
307
+ selectionBuilder.append(" AND ${ContactsContract.Data.MIMETYPE} IN ($mimePlaceholders)")
308
+ selectionArgs.addAll(requestedMimeTypes)
309
+ }
310
+
311
+ resolver
312
+ .query(
313
+ ContactsContract.Data.CONTENT_URI,
314
+ dataColumns,
315
+ selectionBuilder.toString(),
316
+ selectionArgs.toTypedArray(),
317
+ sortOrder,
318
+ )?.use { c ->
319
+ val idxContactId = c.getColumnIndexOrThrow(ContactsContract.Data.CONTACT_ID)
320
+ val idxMimeType = c.getColumnIndexOrThrow(ContactsContract.Data.MIMETYPE)
321
+
322
+ // Cache column indexes to avoid repeated lookups inside the loop.
323
+ val idxDisplayName =
324
+ if (includeName) {
325
+ c.getColumnIndex(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME)
326
+ } else {
327
+ -1
328
+ }
329
+
330
+ val idxPhoneNumber =
331
+ if (includePhones) {
332
+ c.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER)
333
+ } else {
334
+ -1
335
+ }
336
+ val idxPhoneType =
337
+ if (includePhones) {
338
+ c.getColumnIndex(ContactsContract.CommonDataKinds.Phone.TYPE)
339
+ } else {
340
+ -1
341
+ }
342
+
343
+ val idxEmailAddress =
344
+ if (includeEmails) {
345
+ c.getColumnIndex(ContactsContract.CommonDataKinds.Email.ADDRESS)
346
+ } else {
347
+ -1
348
+ }
349
+ val idxEmailType =
350
+ if (includeEmails) {
351
+ c.getColumnIndex(ContactsContract.CommonDataKinds.Email.TYPE)
352
+ } else {
353
+ -1
354
+ }
355
+
356
+ val idxOrgCompany =
357
+ if (includeOrganization) {
358
+ c.getColumnIndex(ContactsContract.CommonDataKinds.Organization.COMPANY)
359
+ } else {
360
+ -1
361
+ }
362
+ val idxOrgTitle =
363
+ if (includeOrganization) {
364
+ c.getColumnIndex(ContactsContract.CommonDataKinds.Organization.TITLE)
365
+ } else {
366
+ -1
367
+ }
368
+ val idxOrgDepartment =
369
+ if (includeOrganization) {
370
+ c.getColumnIndex(ContactsContract.CommonDataKinds.Organization.DEPARTMENT)
371
+ } else {
372
+ -1
373
+ }
374
+
375
+ val idxAddrType =
376
+ if (includeAddresses) {
377
+ c.getColumnIndex(ContactsContract.CommonDataKinds.StructuredPostal.TYPE)
378
+ } else {
379
+ -1
380
+ }
381
+ val idxAddrStreet =
382
+ if (includeAddresses) {
383
+ c.getColumnIndex(ContactsContract.CommonDataKinds.StructuredPostal.STREET)
384
+ } else {
385
+ -1
386
+ }
387
+ val idxAddrCity =
388
+ if (includeAddresses) {
389
+ c.getColumnIndex(ContactsContract.CommonDataKinds.StructuredPostal.CITY)
390
+ } else {
391
+ -1
392
+ }
393
+ val idxAddrRegion =
394
+ if (includeAddresses) {
395
+ c.getColumnIndex(ContactsContract.CommonDataKinds.StructuredPostal.REGION)
396
+ } else {
397
+ -1
398
+ }
399
+ val idxAddrPostcode =
400
+ if (includeAddresses) {
401
+ c.getColumnIndex(ContactsContract.CommonDataKinds.StructuredPostal.POSTCODE)
402
+ } else {
403
+ -1
404
+ }
405
+ val idxAddrCountry =
406
+ if (includeAddresses) {
407
+ c.getColumnIndex(ContactsContract.CommonDataKinds.StructuredPostal.COUNTRY)
408
+ } else {
409
+ -1
410
+ }
411
+
412
+ while (c.moveToNext()) {
413
+ val contactId = c.getString(idxContactId) ?: continue
414
+ val bucket = acc[contactId] ?: continue
415
+
416
+ val mimeType = c.getString(idxMimeType)
417
+
418
+ when (mimeType) {
419
+ PeopleUtils.MIME_NAME -> {
420
+ if (idxDisplayName >= 0) {
421
+ bucket.displayName = c.getString(idxDisplayName)
422
+ }
423
+ }
424
+
425
+ PeopleUtils.MIME_PHONE -> {
426
+ if (idxPhoneNumber >= 0 && idxPhoneType >= 0) {
427
+ val number = c.getString(idxPhoneNumber)
428
+ val type = c.getInt(idxPhoneType)
429
+ val rawLabel =
430
+ ContactsContract.CommonDataKinds.Phone
431
+ .getTypeLabel(context.resources, type, "")
432
+ .toString()
433
+ val label = PeopleUtils.normalizeLabel(rawLabel)
434
+ if (number != null) bucket.phones.add(ContactField(label, number))
435
+ }
436
+ }
437
+
438
+ PeopleUtils.MIME_EMAIL -> {
439
+ if (idxEmailAddress >= 0 && idxEmailType >= 0) {
440
+ val address = c.getString(idxEmailAddress)
441
+ val type = c.getInt(idxEmailType)
442
+ val label =
443
+ PeopleUtils.normalizeLabel(
444
+ ContactsContract.CommonDataKinds.Email
445
+ .getTypeLabel(context.resources, type, "")
446
+ .toString(),
447
+ )
448
+ if (address != null) bucket.emails.add(ContactField(label, address))
449
+ }
450
+ }
451
+
452
+ PeopleUtils.MIME_ORG -> {
453
+ if (idxOrgCompany >= 0 || idxOrgTitle >= 0 || idxOrgDepartment >= 0) {
454
+ bucket.organization =
455
+ ContactOrganization(
456
+ company = if (idxOrgCompany >= 0) c.getString(idxOrgCompany) else null,
457
+ title = if (idxOrgTitle >= 0) c.getString(idxOrgTitle) else null,
458
+ department = if (idxOrgDepartment >= 0) c.getString(idxOrgDepartment) else null,
459
+ )
460
+ }
461
+ }
462
+
463
+ PeopleUtils.MIME_ADDRESS -> {
464
+ if (idxAddrType >= 0) {
465
+ val type = c.getInt(idxAddrType)
466
+ val label =
467
+ PeopleUtils.normalizeLabel(
468
+ ContactsContract.CommonDataKinds.StructuredPostal
469
+ .getTypeLabel(context.resources, type, "")
470
+ .toString(),
471
+ )
472
+
473
+ bucket.addresses.add(
474
+ PostalAddress(
475
+ label = label,
476
+ street = if (idxAddrStreet >= 0) c.getString(idxAddrStreet) else null,
477
+ city = if (idxAddrCity >= 0) c.getString(idxAddrCity) else null,
478
+ region = if (idxAddrRegion >= 0) c.getString(idxAddrRegion) else null,
479
+ postcode = if (idxAddrPostcode >= 0) c.getString(idxAddrPostcode) else null,
480
+ country = if (idxAddrCountry >= 0) c.getString(idxAddrCountry) else null,
481
+ ),
482
+ )
483
+ }
484
+ }
485
+
486
+ ContactsContract.CommonDataKinds.Photo.CONTENT_ITEM_TYPE -> {
487
+ if (includeImage) {
488
+ PeopleLogger.debug("Image data detected for contact $contactId")
489
+ }
490
+ }
491
+ }
492
+ }
493
+ }
494
+ }
495
+
496
+ // Preserve original order of baseContacts
497
+ return baseContacts.map { base ->
498
+ val bucket = acc[base.id]
499
+ base.copy(
500
+ displayName = bucket?.displayName ?: base.displayName,
501
+ organization = bucket?.organization,
502
+ phones = bucket?.phones ?: mutableListOf(),
503
+ emails = bucket?.emails ?: mutableListOf(),
504
+ addresses = bucket?.addresses ?: mutableListOf(),
505
+ )
506
+ }
507
+ }
508
+
509
+ // -----------------------------------------------------------------------------
510
+ // Read Operations (Systemic & Picker)
511
+ // -----------------------------------------------------------------------------
512
+
513
+ /**
514
+ * Extracts contact details from a specific URI returned by the Picker.
515
+ * Returns the native ContactData model.
516
+ */
517
+ fun getContactFromUri(
518
+ contactUri: Uri,
519
+ projection: List<String>,
520
+ ): ContactData? {
521
+ var contactId: String? = null
522
+ var lookupKey: String? = null
523
+
524
+ context.contentResolver
525
+ .query(
526
+ contactUri,
527
+ arrayOf(ContactsContract.Contacts._ID, ContactsContract.Contacts.LOOKUP_KEY),
528
+ null,
529
+ null,
530
+ null,
531
+ )?.use {
532
+ if (it.moveToFirst()) {
533
+ contactId = it.getString(ContactsContract.Contacts._ID)
534
+ lookupKey = it.getString(ContactsContract.Contacts.LOOKUP_KEY)
535
+ }
536
+ }
537
+
538
+ val id = contactId ?: return null
539
+ return fillContactData(id, PeopleUtils.createEmptyContact(id, lookupKey), projection)
540
+ }
541
+
542
+ /**
543
+ * Retrieves a paginated list of contacts using native models.
544
+ */
545
+ fun getContacts(
546
+ projection: List<String>,
547
+ limit: Int,
548
+ offset: Int,
549
+ ): Pair<List<ContactData>, Int> {
550
+ val resolver = context.contentResolver
551
+
552
+ // Never pass SQL functions (e.g. COUNT(_ID)) in projection: some providers reject non-column tokens.
553
+ // Use a second safe query with minimal projection and read cursor.count instead.
554
+ val totalCount =
555
+ resolver
556
+ .query(
557
+ ContactsContract.Contacts.CONTENT_URI,
558
+ arrayOf(ContactsContract.Contacts._ID),
559
+ null,
560
+ null,
561
+ null,
562
+ )?.use { it.count } ?: 0
563
+
564
+ val baseContacts = mutableListOf<ContactData>()
565
+
566
+ // Fetch paginated contact IDs
567
+ resolver
568
+ .query(
569
+ ContactsContract.Contacts.CONTENT_URI,
570
+ arrayOf(ContactsContract.Contacts._ID, ContactsContract.Contacts.LOOKUP_KEY),
571
+ null,
572
+ null,
573
+ "${ContactsContract.Contacts._ID} ASC LIMIT $limit OFFSET $offset",
574
+ )?.use { c ->
575
+ while (c.moveToNext()) {
576
+ val id = c.getString(ContactsContract.Contacts._ID) ?: continue
577
+ val lookupKey = c.getString(ContactsContract.Contacts.LOOKUP_KEY)
578
+ baseContacts.add(PeopleUtils.createEmptyContact(id, lookupKey))
579
+ }
580
+ }
581
+
582
+ val contacts = fillContactsDataBulk(baseContacts, projection)
583
+ return Pair(contacts, totalCount)
584
+ }
585
+
586
+ /**
587
+ * Retrieves a single contact by its unique ID using native models.
588
+ */
589
+ fun getContactById(
590
+ id: String,
591
+ projection: List<String>,
592
+ ): ContactData? {
593
+ context.contentResolver
594
+ .query(
595
+ ContactsContract.Contacts.CONTENT_URI,
596
+ arrayOf(ContactsContract.Contacts._ID, ContactsContract.Contacts.LOOKUP_KEY),
597
+ "${ContactsContract.Contacts._ID} = ?",
598
+ arrayOf(id),
599
+ null,
600
+ )?.use {
601
+ if (it.moveToFirst()) {
602
+ val lookupKey = it.getString(ContactsContract.Contacts.LOOKUP_KEY)
603
+ return fillContactData(id, PeopleUtils.createEmptyContact(id, lookupKey), projection)
604
+ }
605
+ }
606
+ return null
607
+ }
608
+
609
+ /**
610
+ * Searches for contacts matching a query string using native models.
611
+ */
612
+ fun searchContacts(
613
+ query: String,
614
+ projection: List<String>,
615
+ limit: Int,
616
+ ): Pair<List<ContactData>, Int> {
617
+ val matchedIds = mutableSetOf<String>()
618
+ val selection =
619
+ """
620
+ ${ContactsContract.Data.MIMETYPE} IN (?, ?, ?) AND
621
+ (${ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME} LIKE ? OR
622
+ ${ContactsContract.CommonDataKinds.Phone.NUMBER} LIKE ? OR
623
+ ${ContactsContract.CommonDataKinds.Email.ADDRESS} LIKE ?)
624
+ """.trimIndent()
625
+
626
+ val args =
627
+ arrayOf(PeopleUtils.MIME_NAME, PeopleUtils.MIME_PHONE, PeopleUtils.MIME_EMAIL, "%$query%", "%$query%", "%$query%")
628
+
629
+ context.contentResolver
630
+ .query(
631
+ ContactsContract.Data.CONTENT_URI,
632
+ arrayOf(ContactsContract.Data.CONTACT_ID),
633
+ selection,
634
+ args,
635
+ null,
636
+ )?.use { c ->
637
+ while (c.moveToNext()) {
638
+ c.getString(ContactsContract.Data.CONTACT_ID)?.let { matchedIds.add(it) }
639
+ }
640
+ }
641
+
642
+ val results = matchedIds.take(limit).mapNotNull { getContactById(it, projection) }
643
+ return Pair(results, matchedIds.size)
644
+ }
645
+
646
+ // -----------------------------------------------------------------------------
647
+ // Group Management
648
+ // -----------------------------------------------------------------------------
649
+
650
+ /**
651
+ * Lists all available contact groups as native Maps.
652
+ */
653
+ fun listGroups(): List<GroupData> {
654
+ val groups = mutableListOf<GroupData>()
655
+ context.contentResolver
656
+ .query(
657
+ ContactsContract.Groups.CONTENT_URI,
658
+ arrayOf(
659
+ ContactsContract.Groups._ID,
660
+ ContactsContract.Groups.TITLE,
661
+ ContactsContract.Groups.ACCOUNT_NAME,
662
+ ContactsContract.Groups.GROUP_IS_READ_ONLY,
663
+ ),
664
+ null,
665
+ null,
666
+ "${ContactsContract.Groups.TITLE} ASC",
667
+ )?.use { c ->
668
+ while (c.moveToNext()) {
669
+ groups.add(
670
+ GroupData(
671
+ id = c.getString(ContactsContract.Groups._ID) ?: "",
672
+ name = c.getString(ContactsContract.Groups.TITLE) ?: "",
673
+ source = c.getString(ContactsContract.Groups.ACCOUNT_NAME) ?: "local",
674
+ readOnly = c.getInt(ContactsContract.Groups.GROUP_IS_READ_ONLY) == 1,
675
+ ),
676
+ )
677
+ }
678
+ }
679
+ return groups
680
+ }
681
+
682
+ /**
683
+ * Creates a new group.
684
+ *
685
+ * @param name The name of the new group.
686
+ * @return The created group model.
687
+ */
688
+ fun createGroup(name: String): GroupData {
689
+ val values = ContentValues().apply { put(ContactsContract.Groups.TITLE, name) }
690
+ val uri =
691
+ context.contentResolver.insert(ContactsContract.Groups.CONTENT_URI, values)
692
+ ?: throw PeopleError.Unavailable(PeopleErrorMessages.FAILED_TO_CREATE_GROUP)
693
+
694
+ context.contentResolver
695
+ .query(
696
+ uri,
697
+ arrayOf(
698
+ ContactsContract.Groups._ID,
699
+ ContactsContract.Groups.TITLE,
700
+ ContactsContract.Groups.ACCOUNT_NAME,
701
+ ContactsContract.Groups.GROUP_IS_READ_ONLY,
702
+ ),
703
+ null,
704
+ null,
705
+ null,
706
+ )?.use { c ->
707
+ if (c.moveToFirst()) {
708
+ return GroupData(
709
+ id = c.getString(ContactsContract.Groups._ID) ?: "",
710
+ name = c.getString(ContactsContract.Groups.TITLE) ?: "",
711
+ source = c.getString(ContactsContract.Groups.ACCOUNT_NAME) ?: "local",
712
+ readOnly = c.getInt(ContactsContract.Groups.GROUP_IS_READ_ONLY) == 1,
713
+ )
714
+ }
715
+ }
716
+ throw PeopleError.Unavailable(PeopleErrorMessages.FAILED_TO_CREATE_GROUP)
717
+ }
718
+
719
+ /**
720
+ * Deletes a group.
721
+ *
722
+ * @param groupId The ID of the group to delete.
723
+ */
724
+ fun deleteGroup(groupId: String) {
725
+ val uri =
726
+ Uri
727
+ .withAppendedPath(ContactsContract.Groups.CONTENT_URI, groupId)
728
+ // To prevent accidental deletion of all groups, some systems require this
729
+ .buildUpon()
730
+ .appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true")
731
+ .build()
732
+
733
+ if (context.contentResolver.delete(uri, null, null) <= 0) {
734
+ throw PeopleError.Unavailable(PeopleErrorMessages.FAILED_TO_DELETE_GROUP)
735
+ }
736
+ }
737
+
738
+ /**
739
+ * Adds multiple contacts to a group.
740
+ *
741
+ * @param groupId The ID of the group.
742
+ * @param contactIds A list of contact IDs to add.
743
+ */
744
+ fun addPeopleToGroup(
745
+ groupId: String,
746
+ contactIds: List<String>,
747
+ ) {
748
+ for (id in contactIds) {
749
+ val values =
750
+ ContentValues().apply {
751
+ put(ContactsContract.CommonDataKinds.GroupMembership.RAW_CONTACT_ID, id)
752
+ put(ContactsContract.CommonDataKinds.GroupMembership.GROUP_ROW_ID, groupId)
753
+ put(
754
+ ContactsContract.CommonDataKinds.GroupMembership.MIMETYPE,
755
+ ContactsContract.CommonDataKinds.GroupMembership.CONTENT_ITEM_TYPE,
756
+ )
757
+ }
758
+ if (context.contentResolver.insert(ContactsContract.Data.CONTENT_URI, values) == null) {
759
+ throw PeopleError.Unavailable(PeopleErrorMessages.FAILED)
760
+ }
761
+ }
762
+ }
763
+
764
+ /**
765
+ * Removes multiple contacts from a group.
766
+ *
767
+ * @param groupId The ID of the group.
768
+ * @param contactIds A list of contact IDs to remove.
769
+ */
770
+ fun removePeopleFromGroup(
771
+ groupId: String,
772
+ contactIds: List<String>,
773
+ ) {
774
+ // Define the selection criteria with formatted string to respect line length limits
775
+ val where =
776
+ """
777
+ ${ContactsContract.CommonDataKinds.GroupMembership.GROUP_ROW_ID} = ? AND
778
+ ${ContactsContract.CommonDataKinds.GroupMembership.RAW_CONTACT_ID} = ? AND
779
+ ${ContactsContract.CommonDataKinds.GroupMembership.MIMETYPE} = ?
780
+ """.trimIndent()
781
+
782
+ for (id in contactIds) {
783
+ val args =
784
+ arrayOf(
785
+ groupId,
786
+ id,
787
+ ContactsContract.CommonDataKinds.GroupMembership.CONTENT_ITEM_TYPE,
788
+ )
789
+
790
+ val deletedRows =
791
+ context.contentResolver.delete(
792
+ ContactsContract.Data.CONTENT_URI,
793
+ where,
794
+ args,
795
+ )
796
+
797
+ if (deletedRows == 0) {
798
+ PeopleLogger.error("Failed to remove contact $id from group $groupId")
799
+ throw PeopleError.Unavailable(PeopleErrorMessages.FAILED)
800
+ }
801
+ }
802
+ }
803
+
804
+ // -----------------------------------------------------------------------------
805
+ // CRUD Operations
806
+ // -----------------------------------------------------------------------------
807
+
808
+ /**
809
+ * Creates a new contact using native parameters.
810
+ */
811
+ fun createContact(
812
+ givenName: String?,
813
+ familyName: String?,
814
+ phones: List<String>,
815
+ emails: List<String>,
816
+ ): ContactData {
817
+ val ops = ArrayList<android.content.ContentProviderOperation>()
818
+
819
+ ops.add(
820
+ android.content.ContentProviderOperation
821
+ .newInsert(ContactsContract.RawContacts.CONTENT_URI)
822
+ .withValue(ContactsContract.RawContacts.ACCOUNT_TYPE, ACCOUNT_TYPE)
823
+ .withValue(ContactsContract.RawContacts.ACCOUNT_NAME, ACCOUNT_NAME)
824
+ .build(),
825
+ )
826
+
827
+ ops.add(
828
+ android.content.ContentProviderOperation
829
+ .newInsert(ContactsContract.Data.CONTENT_URI)
830
+ .withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0)
831
+ .withValue(ContactsContract.Data.MIMETYPE, PeopleUtils.MIME_NAME)
832
+ .withValue(ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME, givenName)
833
+ .withValue(ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME, familyName)
834
+ .build(),
835
+ )
836
+
837
+ phones.forEach { num ->
838
+ ops.add(
839
+ android.content.ContentProviderOperation
840
+ .newInsert(ContactsContract.Data.CONTENT_URI)
841
+ .withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0)
842
+ .withValue(ContactsContract.Data.MIMETYPE, PeopleUtils.MIME_PHONE)
843
+ .withValue(ContactsContract.CommonDataKinds.Phone.NUMBER, num)
844
+ .withValue(ContactsContract.CommonDataKinds.Phone.TYPE, ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE)
845
+ .build(),
846
+ )
847
+ }
848
+
849
+ val newContactId =
850
+ try {
851
+ val results = context.contentResolver.applyBatch(ContactsContract.AUTHORITY, ops)
852
+ results[0].uri?.lastPathSegment
853
+ ?: throw PeopleError.Unavailable(PeopleErrorMessages.FAILED_TO_CREATE_CONTACT)
854
+ } catch (e: Exception) {
855
+ PeopleLogger.error("Failed to create contact", e)
856
+ throw PeopleError.Unavailable(PeopleErrorMessages.FAILED_TO_CREATE_CONTACT)
857
+ }
858
+
859
+ return getContactById(newContactId, listOf("name", "phones", "emails"))
860
+ ?: throw PeopleError.Unavailable(PeopleErrorMessages.FAILED_TO_RETRIEVE_CONTACT)
861
+ }
862
+
863
+ /**
864
+ * Updates contact names using native parameters.
865
+ * Returns a native ContactData model upon success.
866
+ */
867
+ fun updateContact(
868
+ contactId: String,
869
+ givenName: String?,
870
+ familyName: String?,
871
+ ): ContactData {
872
+ if (!isAppOwned(contactId)) {
873
+ PeopleLogger.error("Cannot update a contact that is not owned by the app.")
874
+ throw PeopleError.PermissionDenied("Cannot update a contact that is not owned by the app.")
875
+ }
876
+
877
+ val ops = ArrayList<android.content.ContentProviderOperation>()
878
+
879
+ ops.add(
880
+ android.content.ContentProviderOperation
881
+ .newUpdate(ContactsContract.Data.CONTENT_URI)
882
+ .withSelection(
883
+ "${ContactsContract.Data.CONTACT_ID} = ? AND ${ContactsContract.Data.MIMETYPE} = ?",
884
+ arrayOf(contactId, PeopleUtils.MIME_NAME),
885
+ ).withValue(ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME, givenName)
886
+ .withValue(ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME, familyName)
887
+ .build(),
888
+ )
889
+
890
+ return try {
891
+ context.contentResolver.applyBatch(ContactsContract.AUTHORITY, ops)
892
+ // Re-fetch using native model
893
+ getContactById(contactId, listOf("name", "phones", "emails"))
894
+ ?: throw PeopleError.Unavailable(PeopleErrorMessages.UPDATE_FAILED)
895
+ } catch (e: Exception) {
896
+ PeopleLogger.error("Failed to update contact", e)
897
+ throw PeopleError.Unavailable(PeopleErrorMessages.UPDATE_FAILED)
898
+ }
899
+ }
900
+
901
+ /**
902
+ * Deletes a contact if it is owned by the app.
903
+ */
904
+ fun deleteContact(contactId: String) {
905
+ if (!isAppOwned(contactId)) {
906
+ throw PeopleError.PermissionDenied("Cannot modify a contact that is not owned by the app.")
907
+ }
908
+ val uri = Uri.withAppendedPath(ContactsContract.Contacts.CONTENT_URI, contactId)
909
+ try {
910
+ if (context.contentResolver.delete(uri, null, null) <= 0) {
911
+ throw PeopleError.InitFailed(PeopleErrorMessages.FAILED)
912
+ }
913
+ } catch (e: Exception) {
914
+ throw PeopleError.InitFailed(PeopleErrorMessages.FAILED)
915
+ }
916
+ }
917
+
918
+ /**
919
+ * Merges two contacts natively.
920
+ * Returns the final state of the destination contact as ContactData.
921
+ */
922
+ fun mergeContacts(
923
+ sourceId: String,
924
+ destId: String,
925
+ ): ContactData {
926
+ if (!isAppOwned(sourceId) || !isAppOwned(destId)) {
927
+ throw PeopleError.PermissionDenied("Cannot merge contacts that are not owned by the app.")
928
+ }
929
+ val ops = ArrayList<android.content.ContentProviderOperation>()
930
+ val rawDestId = getRawContactId(destId) ?: throw PeopleError.Unavailable(PeopleErrorMessages.MERGE_FAILED)
931
+
932
+ context.contentResolver
933
+ .query(
934
+ ContactsContract.Data.CONTENT_URI,
935
+ null,
936
+ "${ContactsContract.Data.CONTACT_ID} = ?",
937
+ arrayOf(sourceId),
938
+ null,
939
+ )?.use {
940
+ while (it.moveToNext()) {
941
+ if (it.getString(ContactsContract.Data.MIMETYPE) != PeopleUtils.MIME_NAME) {
942
+ ops.add(
943
+ android.content.ContentProviderOperation
944
+ .newUpdate(ContactsContract.Data.CONTENT_URI)
945
+ .withSelection(
946
+ "${ContactsContract.Data._ID} = ?",
947
+ arrayOf(it.getString(it.getColumnIndexOrThrow(ContactsContract.Data._ID))),
948
+ ).withValue(ContactsContract.Data.RAW_CONTACT_ID, rawDestId)
949
+ .build(),
950
+ )
951
+ }
952
+ }
953
+ }
954
+
955
+ ops.add(
956
+ android.content.ContentProviderOperation
957
+ .newDelete(
958
+ Uri.withAppendedPath(ContactsContract.Contacts.CONTENT_URI, sourceId),
959
+ ).build(),
960
+ )
961
+
962
+ return try {
963
+ context.contentResolver.applyBatch(ContactsContract.AUTHORITY, ops)
964
+ getContactById(destId, listOf("name", "phones", "emails"))
965
+ ?: throw PeopleError.Unavailable(PeopleErrorMessages.MERGE_FAILED)
966
+ } catch (e: Exception) {
967
+ throw PeopleError.Unavailable(PeopleErrorMessages.MERGE_FAILED)
968
+ }
969
+ }
970
+
971
+ // -----------------------------------------------------------------------------
972
+ // Ownership & IDs Helpers
973
+ // -----------------------------------------------------------------------------
974
+
975
+ private fun isAppOwned(contactId: String): Boolean {
976
+ val rawId = getRawContactId(contactId) ?: return false
977
+ context.contentResolver
978
+ .query(
979
+ ContactsContract.RawContacts.CONTENT_URI,
980
+ arrayOf(ContactsContract.RawContacts.ACCOUNT_TYPE),
981
+ "${ContactsContract.RawContacts._ID} = ?",
982
+ arrayOf(rawId),
983
+ null,
984
+ )?.use {
985
+ if (it.moveToFirst()) return it.getString(0) == ACCOUNT_TYPE
986
+ }
987
+ return false
988
+ }
989
+
990
+ private fun getRawContactId(contactId: String): String? {
991
+ context.contentResolver
992
+ .query(
993
+ ContactsContract.RawContacts.CONTENT_URI,
994
+ arrayOf(ContactsContract.RawContacts._ID),
995
+ "${ContactsContract.RawContacts.CONTACT_ID} = ?",
996
+ arrayOf(contactId),
997
+ null,
998
+ )?.use {
999
+ if (it.moveToFirst()) return it.getString(0)
1000
+ }
1001
+ return null
1002
+ }
1003
+ }