Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,10 +178,31 @@ List<Contact> contacts = await FlutterContacts.getAll(

Both `get()` and `getAll()` default to fetching only ID + display name. Specify `properties` for additional fields.

**Properties:** `name`, `phone`, `email`, `address`, `organization`, `website`, `socialMedia`, `event`, `relation`, `note`, `photoThumbnail`, `photoFullRes`, `favorite` (Android), `ringtone` (Android), `sendToVoicemail` (Android), `timestamp` (Android), `identifiers` (Android). Use `ContactProperties.all` for all properties, or `ContactProperties.allProperties` to exclude photos. Only fetch the properties you need to improve query performance.
**Properties:** `name`, `phone`, `email`, `address`, `organization`, `website`, `socialMedia`, `event`, `relation`, `note`, `photoThumbnail`, `photoFullRes`, `favorite` (Android), `ringtone` (Android), `sendToVoicemail` (Android), `timestamp` (Android), `identifiers` (Android), `dataMimetypes` (Android). Use `ContactProperties.all` for all properties, or `ContactProperties.allProperties` to exclude photos. Only fetch the properties you need to improve query performance. Note: `dataMimetypes` is opt-in even when using `ContactProperties.all` - it must be added to the set explicitly to avoid the extra mimetype scan for callers that do not need it.

**Filters:** `ContactFilter.name()`, `.phone()`, `.email()`, `.group()`, `.ids()`. Phone/email filters use partial match on Android, full match on iOS.

When `ContactProperty.dataMimetypes` is requested **alongside** `ContactProperty.identifiers`, each entry in `contact.android?.identifiers?.rawContacts` carries a `dataMimetypes: List<String>` of every `ContactsContract.Data.MIMETYPE` present for that raw contact (Android only). Useful for distinguishing real telephony contacts from synthetic raw_contacts created by messaging apps (WhatsApp, Viber, Telegram, ...) that never insert a `vnd.android.cursor.item/phone_v2` row:

```dart
final contacts = await FlutterContacts.getAll(properties: {
ContactProperty.name,
ContactProperty.phone,
ContactProperty.identifiers,
ContactProperty.dataMimetypes, // opt-in: adds one narrow SELECT per batch
});

bool isRealTelephonyContact(Contact c) {
final raws = c.android?.identifiers?.rawContacts ?? const [];
if (raws.isEmpty) return true; // device-local contact, no account info
return raws.any(
(rc) => rc.dataMimetypes.contains('vnd.android.cursor.item/phone_v2'),
);
}
```

`dataMimetypes` is opt-in to keep the default `identifiers` path cheap - callers that only need lookup keys or source IDs do not pay for the extra mimetype scan.

### Create

```dart
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,22 @@ data class RawContactInfo(
val rawContactId: String? = null,
val sourceId: String? = null,
val account: Account? = null,
val dataMimetypes: List<String> = emptyList(),
) {
companion object {
fun fromJson(json: Map<String, Any?>?) =
json?.let {
val accountJson = it["account"] as? Map<String, Any?>
val account = accountJson?.let { Account.fromJson(it) }
@Suppress("UNCHECKED_CAST")
val dataMimetypes = (it["dataMimetypes"] as? List<*>)
?.mapNotNull { item -> item as? String }
?: emptyList()
RawContactInfo(
rawContactId = it["rawContactId"] as? String,
sourceId = it["sourceId"] as? String,
account = account,
dataMimetypes = dataMimetypes,
)
}
}
Expand All @@ -26,5 +32,6 @@ data class RawContactInfo(
rawContactId?.let { put("rawContactId", it) }
sourceId?.let { put("sourceId", it) }
account?.let { put("account", it.toJson()) }
if (dataMimetypes.isNotEmpty()) put("dataMimetypes", dataMimetypes)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ object ContactFetcher {

contactData.accounts = AccountUtils.getAccountsForContact(contentResolver, contactId)
if (properties.contains("identifiers")) {
fetchRawContactIdentifiers(contentResolver, listOf(contactId), contactsMap)
fetchRawContactIdentifiers(contentResolver, listOf(contactId), contactsMap, properties)
}
return contactData.toContact(properties, contentResolver)
}
Expand Down Expand Up @@ -280,7 +280,7 @@ object ContactFetcher {
contactsMap[contactId]?.accounts = accountsMap[contactId] ?: emptyList()
}
if (properties.contains("identifiers")) {
fetchRawContactIdentifiers(contentResolver, batchIds, contactsMap)
fetchRawContactIdentifiers(contentResolver, batchIds, contactsMap, properties)
}
batchIds.forEach { contactId ->
contactsMap[contactId]?.let {
Expand Down Expand Up @@ -380,7 +380,7 @@ object ContactFetcher {
}

if (properties.contains("identifiers")) {
fetchRawContactIdentifiers(contentResolver, batchIds, contactsMap)
fetchRawContactIdentifiers(contentResolver, batchIds, contactsMap, properties)
}

results.addAll(
Expand Down Expand Up @@ -450,7 +450,7 @@ object ContactFetcher {
contactData.accounts =
AccountUtils.getAccountsForContact(contentResolver, profileContactId!!)
if (properties.contains("identifiers")) {
fetchRawContactIdentifiers(contentResolver, listOf(profileContactId!!), contactsMap)
fetchRawContactIdentifiers(contentResolver, listOf(profileContactId!!), contactsMap, properties)
}
return contactData.toContact(properties, contentResolver)
}
Expand Down Expand Up @@ -510,8 +510,11 @@ object ContactFetcher {
contentResolver: ContentResolver,
contactIds: List<String>,
contactsMap: MutableMap<String, MutableContact>,
properties: Set<String>,
) {
if (contactIds.isEmpty()) return
val withDataMimetypes = properties.contains("dataMimetypes")
val collectedRawContactIds = if (withDataMimetypes) mutableListOf<String>() else null
contentResolver.queryAndProcess(
RawContacts.CONTENT_URI,
projection =
Expand Down Expand Up @@ -546,8 +549,61 @@ object ContactFetcher {
account = account,
),
)
if (withDataMimetypes && rawContactId != null) {
collectedRawContactIds!!.add(rawContactId)
}
}
}
}

if (!withDataMimetypes || collectedRawContactIds.isNullOrEmpty()) return

val mimetypesByRawContact = fetchDataMimetypesPerRawContact(contentResolver, collectedRawContactIds)
if (mimetypesByRawContact.isEmpty()) return

contactIds.forEach { contactId ->
val contactData = contactsMap[contactId] ?: return@forEach
val updated = contactData.rawContactInfos.map { info ->
val mimetypes = info.rawContactId?.let { mimetypesByRawContact[it] }
if (mimetypes.isNullOrEmpty()) {
info
} else {
// Sort for stable list ordering across repeated fetches.
// Cursor row order is not guaranteed; the Dart side compares
// dataMimetypes positionally in == / hashCode.
info.copy(dataMimetypes = mimetypes.sorted())
}
}
contactData.rawContactInfos.clear()
contactData.rawContactInfos.addAll(updated)
}
}

private fun fetchDataMimetypesPerRawContact(
contentResolver: ContentResolver,
rawContactIds: List<String>,
): Map<String, Set<String>> {
if (rawContactIds.isEmpty()) return emptyMap()
// De-duplicate then batch via BatchHelper to stay under SQLite's bind
// parameter limit. A getAll batch may collect >900 raw_contact IDs
// (multiple raw_contacts per contact), which would otherwise overflow
// SQLITE_MAX_VARIABLE_NUMBER on older Android versions.
val mimetypesByRawContact = mutableMapOf<String, MutableSet<String>>()
BatchHelper.forEachSelectionArgsBatch(rawContactIds.distinct()) { batch ->
val placeholders = batch.joinToString(",") { "?" }
contentResolver.queryAndProcess(
Data.CONTENT_URI,
projection = arrayOf(Data.RAW_CONTACT_ID, Data.MIMETYPE),
selection = "${Data.RAW_CONTACT_ID} IN ($placeholders)",
selectionArgs = batch.toTypedArray(),
) { cursor ->
cursor.forEachRow { row ->
val rawContactId = row.getLongOrNull(Data.RAW_CONTACT_ID)?.toString() ?: return@forEachRow
val mimetype = row.getStringOrNull(Data.MIMETYPE) ?: return@forEachRow
mimetypesByRawContact.getOrPut(rawContactId) { mutableSetOf() }.add(mimetype)
}
}
}
return mimetypesByRawContact
}
}
Loading