@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.
- package/CapKitPeople.podspec +20 -0
- package/LICENSE +21 -0
- package/Package.swift +28 -0
- package/README.md +1177 -0
- package/android/build.gradle +101 -0
- package/android/src/main/AndroidManifest.xml +4 -0
- package/android/src/main/java/io/capkit/people/PeopleImpl.kt +1003 -0
- package/android/src/main/java/io/capkit/people/PeopleObserver.kt +80 -0
- package/android/src/main/java/io/capkit/people/PeoplePlugin.kt +766 -0
- package/android/src/main/java/io/capkit/people/config/PeopleConfig.kt +44 -0
- package/android/src/main/java/io/capkit/people/error/PeopleError.kt +90 -0
- package/android/src/main/java/io/capkit/people/error/PeopleErrorMessages.kt +39 -0
- package/android/src/main/java/io/capkit/people/logger/PeopleLogger.kt +85 -0
- package/android/src/main/java/io/capkit/people/models/ContactModels.kt +64 -0
- package/android/src/main/java/io/capkit/people/utils/PeopleUtils.kt +133 -0
- package/android/src/main/res/.gitkeep +0 -0
- package/dist/docs.json +1449 -0
- package/dist/esm/definitions.d.ts +775 -0
- package/dist/esm/definitions.js +31 -0
- package/dist/esm/definitions.js.map +1 -0
- package/dist/esm/index.d.ts +15 -0
- package/dist/esm/index.js +18 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/web.d.ts +120 -0
- package/dist/esm/web.js +252 -0
- package/dist/esm/web.js.map +1 -0
- package/dist/plugin.cjs +300 -0
- package/dist/plugin.cjs.map +1 -0
- package/dist/plugin.js +303 -0
- package/dist/plugin.js.map +1 -0
- package/ios/Sources/PeoplePlugin/PeopleImpl.swift +463 -0
- package/ios/Sources/PeoplePlugin/PeoplePlugin.swift +627 -0
- package/ios/Sources/PeoplePlugin/PrivacyInfo.xcprivacy +13 -0
- package/ios/Sources/PeoplePlugin/Utils/PeopleUtils.swift +120 -0
- package/ios/Sources/PeoplePlugin/Version.swift +16 -0
- package/ios/Sources/PeoplePlugin/config/PeopleConfig.swift +56 -0
- package/ios/Sources/PeoplePlugin/error/PeopleError.swift +89 -0
- package/ios/Sources/PeoplePlugin/error/PeopleErrorMessages.swift +25 -0
- package/ios/Sources/PeoplePlugin/logger/PeopleLogging.swift +69 -0
- package/ios/Sources/PeoplePlugin/models/ContactModels.swift +68 -0
- package/ios/Tests/PeoplePluginTests/PeoplePluginTests.swift +10 -0
- 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
|
+
}
|