diff --git a/README.md b/README.md index e598b6db..00cb74e2 100644 --- a/README.md +++ b/README.md @@ -178,10 +178,31 @@ List 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` 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 diff --git a/android/src/main/kotlin/co/quis/flutter_contacts/crud/models/contact/RawContactInfo.kt b/android/src/main/kotlin/co/quis/flutter_contacts/crud/models/contact/RawContactInfo.kt index 959ba20d..3779fa03 100644 --- a/android/src/main/kotlin/co/quis/flutter_contacts/crud/models/contact/RawContactInfo.kt +++ b/android/src/main/kotlin/co/quis/flutter_contacts/crud/models/contact/RawContactInfo.kt @@ -7,16 +7,22 @@ data class RawContactInfo( val rawContactId: String? = null, val sourceId: String? = null, val account: Account? = null, + val dataMimetypes: List = emptyList(), ) { companion object { fun fromJson(json: Map?) = json?.let { val accountJson = it["account"] as? Map 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, ) } } @@ -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) } } diff --git a/android/src/main/kotlin/co/quis/flutter_contacts/crud/utils/ContactFetcher.kt b/android/src/main/kotlin/co/quis/flutter_contacts/crud/utils/ContactFetcher.kt index f23a42f6..c1723f4a 100644 --- a/android/src/main/kotlin/co/quis/flutter_contacts/crud/utils/ContactFetcher.kt +++ b/android/src/main/kotlin/co/quis/flutter_contacts/crud/utils/ContactFetcher.kt @@ -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) } @@ -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 { @@ -380,7 +380,7 @@ object ContactFetcher { } if (properties.contains("identifiers")) { - fetchRawContactIdentifiers(contentResolver, batchIds, contactsMap) + fetchRawContactIdentifiers(contentResolver, batchIds, contactsMap, properties) } results.addAll( @@ -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) } @@ -510,8 +510,11 @@ object ContactFetcher { contentResolver: ContentResolver, contactIds: List, contactsMap: MutableMap, + properties: Set, ) { if (contactIds.isEmpty()) return + val withDataMimetypes = properties.contains("dataMimetypes") + val collectedRawContactIds = if (withDataMimetypes) mutableListOf() else null contentResolver.queryAndProcess( RawContacts.CONTENT_URI, projection = @@ -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, + ): Map> { + 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>() + 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 } } diff --git a/example/lib/main.dart b/example/lib/main.dart index a03773ae..0ee4be6a 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -2,9 +2,36 @@ // For a full-fledged contacts app, see https://github.com/QuisApp/flutter_contacts_example import 'dart:async'; +import 'dart:io' show Platform; import 'package:flutter/material.dart'; import 'package:flutter_contacts/flutter_contacts.dart'; +// Predefined mimetypes that a contact's raw_contact may carry under +// `ContactsContract.Data.MIMETYPE`. A contact passes a mimetype filter if any +// of its raw_contacts has at least one data row with the given mimetype. +const _predefinedMimetypes = <_Predicate>[ + _Predicate('phone_v2', 'vnd.android.cursor.item/phone_v2'), + _Predicate('email_v2', 'vnd.android.cursor.item/email_v2'), + _Predicate('name', 'vnd.android.cursor.item/name'), + _Predicate('photo', 'vnd.android.cursor.item/photo'), + _Predicate('postal', 'vnd.android.cursor.item/postal-address_v2'), +]; + +// Predefined `RawContacts.ACCOUNT_TYPE` values. A contact passes an account +// filter if any of its raw_contacts is in one of the checked accounts. +const _predefinedAccountTypes = <_Predicate>[ + _Predicate('Google', 'com.google'), + _Predicate('WhatsApp', 'com.whatsapp'), + _Predicate('Viber', 'com.viber.voip'), + _Predicate('Telegram', 'org.telegram.messenger'), +]; + +class _Predicate { + final String label; + final String value; + const _Predicate(this.label, this.value); +} + void main() => runApp( MaterialApp( theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.blue), @@ -23,6 +50,15 @@ class _ContactListPageState extends State { StreamSubscription? _sub; bool _denied = false; + // OR-combined: a contact passes if any of its raw_contacts matches any of + // these mimetypes OR any of these account types. Empty sets mean "no + // constraint" - i.e. all contacts pass. + final Set _requireMimetypes = {}; + final Set _requireAccountTypes = {}; + + bool get _filterActive => + _requireMimetypes.isNotEmpty || _requireAccountTypes.isNotEmpty; + @override void initState() { super.initState(); @@ -53,50 +89,255 @@ class _ContactListPageState extends State { Future _load() async { final contacts = await FlutterContacts.getAll( - properties: {ContactProperty.photoThumbnail}, + properties: { + ContactProperty.photoThumbnail, + if (_filterActive && Platform.isAndroid) ...{ + ContactProperty.identifiers, + ContactProperty.dataMimetypes, + }, + }, ); setState(() => _contacts = contacts); } + bool _passesFilter(Contact c) { + if (!Platform.isAndroid || !_filterActive) return true; + final raws = c.android?.identifiers?.rawContacts ?? const []; + if (raws.isEmpty) return true; // device-local; vendor ROMs may hide account + return raws.any( + (rc) => + rc.dataMimetypes.any(_requireMimetypes.contains) || + _requireAccountTypes.contains(rc.account?.type), + ); + } + void _open(Widget page) => Navigator.push(context, MaterialPageRoute(builder: (_) => page)); - @override - Widget build(BuildContext context) => Scaffold( - appBar: AppBar(title: const Text('Contacts')), - body: _denied - ? const Center(child: Text('Contact permission not granted')) - : _contacts == null - ? const Center(child: CircularProgressIndicator()) - : ListView.builder( - itemCount: _contacts!.length, - itemBuilder: (_, i) { - final c = _contacts![i]; - return ListTile( - leading: CircleAvatar( - backgroundImage: c.photo?.thumbnail != null - ? MemoryImage(c.photo!.thumbnail!) - : null, - child: c.photo?.thumbnail == null - ? const Icon(Icons.person) - : null, + Widget _buildActiveFilterChips() { + final mimetypeLabels = { + for (final p in _predefinedMimetypes) p.value: p.label, + }; + final accountLabels = { + for (final p in _predefinedAccountTypes) p.value: p.label, + }; + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: Wrap( + spacing: 6, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + const Text('Pass if any:', style: TextStyle(fontSize: 12)), + for (final mt in _requireMimetypes) + Chip( + label: Text('mime: ${mimetypeLabels[mt] ?? mt}'), + visualDensity: VisualDensity.compact, + onDeleted: () { + setState(() => _requireMimetypes.remove(mt)); + _load(); + }, + ), + for (final at in _requireAccountTypes) + Chip( + label: Text('account: ${accountLabels[at] ?? at}'), + visualDensity: VisualDensity.compact, + onDeleted: () { + setState(() => _requireAccountTypes.remove(at)); + _load(); + }, + ), + ], + ), + ); + } + + Future _openFilterSheet() async { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (sheetCtx) => StatefulBuilder( + builder: (sheetCtx, setSheet) => SafeArea( + child: ListView( + shrinkWrap: true, + padding: const EdgeInsets.symmetric(vertical: 8), + children: [ + const Padding( + padding: EdgeInsets.fromLTRB(16, 8, 16, 4), + child: Text( + 'Android filter (OR across all checked rows)', + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + const Padding( + padding: EdgeInsets.fromLTRB(16, 0, 16, 8), + child: Text( + 'A contact passes if any of its raw_contacts matches any ' + 'checked mimetype OR any checked account type. Empty = no filter.', + style: TextStyle(fontSize: 12), + ), + ), + const Divider(height: 1), + const Padding( + padding: EdgeInsets.fromLTRB(16, 12, 16, 4), + child: Text( + 'Require mimetype', + style: TextStyle(fontWeight: FontWeight.w600), ), - title: Text(c.displayName ?? '(No name)'), - onTap: () => _open(ContactPage(id: c.id!)), - ); - }, + ), + for (final p in _predefinedMimetypes) + CheckboxListTile( + dense: true, + title: Text(p.label), + subtitle: Text(p.value, style: const TextStyle(fontSize: 11)), + value: _requireMimetypes.contains(p.value), + onChanged: (v) { + setSheet(() { + v == true + ? _requireMimetypes.add(p.value) + : _requireMimetypes.remove(p.value); + }); + }, + ), + const Divider(height: 1), + const Padding( + padding: EdgeInsets.fromLTRB(16, 12, 16, 4), + child: Text( + 'Require account type', + style: TextStyle(fontWeight: FontWeight.w600), + ), + ), + for (final p in _predefinedAccountTypes) + CheckboxListTile( + dense: true, + title: Text(p.label), + subtitle: Text(p.value, style: const TextStyle(fontSize: 11)), + value: _requireAccountTypes.contains(p.value), + onChanged: (v) { + setSheet(() { + v == true + ? _requireAccountTypes.add(p.value) + : _requireAccountTypes.remove(p.value); + }); + }, + ), + if (_filterActive) + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), + child: TextButton.icon( + icon: const Icon(Icons.clear), + label: const Text('Clear all'), + onPressed: () => setSheet(() { + _requireMimetypes.clear(); + _requireAccountTypes.clear(); + }), + ), + ), + ], ), - floatingActionButton: FloatingActionButton( - child: const Icon(Icons.add), - onPressed: () => _open(const EditContactPage()), - ), - ); + ), + ), + ); + if (mounted) { + setState(() {}); + await _load(); + } + } + + @override + Widget build(BuildContext context) { + final all = _contacts ?? const []; + final visible = _filterActive ? all.where(_passesFilter).toList() : all; + final filteredOut = all.length - visible.length; + return Scaffold( + appBar: AppBar( + title: _contacts == null + ? const Text('Contacts') + : Text( + _filterActive + ? 'Contacts (${visible.length} of ${all.length}, $filteredOut hidden)' + : 'Contacts (${all.length})', + ), + actions: [ + if (Platform.isAndroid) + IconButton( + tooltip: 'Configure Android filter', + icon: Icon( + _filterActive ? Icons.filter_alt : Icons.filter_alt_outlined, + ), + onPressed: _openFilterSheet, + ), + ], + ), + body: _denied + ? const Center(child: Text('Contact permission not granted')) + : _contacts == null + ? const Center(child: CircularProgressIndicator()) + : Column( + children: [ + if (_filterActive) _buildActiveFilterChips(), + Expanded( + child: ListView.builder( + itemCount: visible.length, + itemBuilder: (_, i) { + final c = visible[i]; + return ListTile( + leading: CircleAvatar( + backgroundImage: c.photo?.thumbnail != null + ? MemoryImage(c.photo!.thumbnail!) + : null, + child: c.photo?.thumbnail == null + ? const Icon(Icons.person) + : null, + ), + title: Text(c.displayName ?? '(No name)'), + onTap: () => _open(ContactPage(id: c.id!)), + ); + }, + ), + ), + ], + ), + floatingActionButton: FloatingActionButton( + child: const Icon(Icons.add), + onPressed: () => _open(const EditContactPage()), + ), + ); + } } class ContactPage extends StatelessWidget { final String id; const ContactPage({super.key, required this.id}); + List _buildRawContactsSection(Contact c) { + final raws = c.android?.identifiers?.rawContacts ?? const []; + if (raws.isEmpty) return const []; + return [ + const Divider(), + const Padding( + padding: EdgeInsets.fromLTRB(16, 12, 16, 4), + child: Text( + 'Raw contacts (Android)', + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + for (final rc in raws) + ListTile( + leading: const Icon(Icons.account_tree_outlined), + title: Text(rc.account?.type ?? '(no account)'), + subtitle: Text( + 'rawContactId=${rc.rawContactId}\n' + 'mimetypes: ${rc.dataMimetypes.join(", ")}', + style: const TextStyle(fontFamily: 'monospace', fontSize: 11), + ), + isThreeLine: true, + ), + ]; + } + Future _load() => FlutterContacts.get( id, properties: { @@ -105,6 +346,10 @@ class ContactPage extends StatelessWidget { ContactProperty.email, ContactProperty.photoThumbnail, ContactProperty.photoFullRes, + if (Platform.isAndroid) ...{ + ContactProperty.identifiers, + ContactProperty.dataMimetypes, + }, }, ); @@ -146,6 +391,7 @@ class ContactPage extends StatelessWidget { leading: const Icon(Icons.email), title: Text(e.address), ), + if (Platform.isAndroid) ..._buildRawContactsSection(c), ], ), ); diff --git a/lib/api/crud_api.dart b/lib/api/crud_api.dart index 745323bf..e6b66ee3 100644 --- a/lib/api/crud_api.dart +++ b/lib/api/crud_api.dart @@ -32,6 +32,7 @@ class CrudApi { bool androidLookup = false, }) async { final props = properties ?? ContactProperties.none; + _throwIfPropertiesInvalid(props); final result = await _channel.invokeMethod( 'crud.get', _withIosNotes({ @@ -51,6 +52,7 @@ class CrudApi { int? limit, }) async { final props = properties ?? ContactProperties.none; + _throwIfPropertiesInvalid(props); final result = await _channel.invokeMethod( 'crud.getAll', _withIosNotes({ @@ -63,6 +65,22 @@ class CrudApi { return JsonHelpers.decodeList(result, Contact.fromJson); } + /// Throws an [ArgumentError] when [properties] combines incompatible entries. + /// + /// Fails fast on the Dart side (before any platform-channel round-trip) so + /// developers see the misconfiguration as an exception rather than a silent + /// empty result. + static void _throwIfPropertiesInvalid(Set properties) { + if (properties.contains(ContactProperty.dataMimetypes) && + !properties.contains(ContactProperty.identifiers)) { + throw ArgumentError( + 'ContactProperty.dataMimetypes requires ContactProperty.identifiers: ' + 'the field is exposed via Contact.android.identifiers.rawContacts[*], ' + 'so without identifiers there is no RawContact list to populate.', + ); + } + } + Future create(Contact contact, {Account? account}) async { final result = await _channel.invokeMethod( 'crud.create', diff --git a/lib/models/android/raw_contact.dart b/lib/models/android/raw_contact.dart index e5391d94..d02b7c0c 100644 --- a/lib/models/android/raw_contact.dart +++ b/lib/models/android/raw_contact.dart @@ -14,39 +14,71 @@ class RawContact { /// Account this raw contact belongs to. final Account? account; - const RawContact({this.rawContactId, this.sourceId, this.account}); + /// Data mimetypes present for this raw contact (Android only). + /// + /// Lists the `ContactsContract.Data.MIMETYPE` values that exist for this raw + /// contact - e.g. `vnd.android.cursor.item/phone_v2`, + /// `vnd.android.cursor.item/email_v2`, plus any source-specific entries + /// written by messaging apps (e.g. WhatsApp / Viber). + /// + /// Useful for callers that need to distinguish real telephony contacts from + /// synthetic raw_contacts created by messaging apps: + /// + /// ```dart + /// final hasPhoneRow = rc.dataMimetypes.contains( + /// 'vnd.android.cursor.item/phone_v2', + /// ); + /// ``` + /// + /// Populated only when **both** [ContactProperty.identifiers] and + /// [ContactProperty.dataMimetypes] are requested. Values are sorted lexically + /// so the list is stable across repeated fetches. Empty on iOS / macOS. + final List dataMimetypes; + + const RawContact({ + this.rawContactId, + this.sourceId, + this.account, + this.dataMimetypes = const [], + }); Map toJson() { final json = {}; JsonHelpers.encode(json, 'rawContactId', rawContactId); JsonHelpers.encode(json, 'sourceId', sourceId); JsonHelpers.encode(json, 'account', account, (a) => a.toJson()); + if (dataMimetypes.isNotEmpty) json['dataMimetypes'] = dataMimetypes; return json; } static RawContact? fromJson(Map? json) { if (json == null) return null; final accountJson = json['account']; - final account = accountJson != null - ? Account.fromJson(accountJson as Map) - : null; - final rawContactId = JsonHelpers.decode(json['rawContactId']); - final sourceId = JsonHelpers.decode(json['sourceId']); - if (rawContactId == null && sourceId == null && account == null) { - return null; - } - return RawContact( - rawContactId: rawContactId, - sourceId: sourceId, - account: account, + final raw = RawContact( + rawContactId: JsonHelpers.decode(json['rawContactId']), + sourceId: JsonHelpers.decode(json['sourceId']), + account: accountJson == null + ? null + : Account.fromJson(accountJson as Map), + dataMimetypes: List.from( + json['dataMimetypes'] as List? ?? const [], + ), ); + return raw._isEmpty ? null : raw; } + bool get _isEmpty => + rawContactId == null && + sourceId == null && + account == null && + dataMimetypes.isEmpty; + @override String toString() => JsonHelpers.formatToString('RawContact', { 'rawContactId': rawContactId, 'sourceId': sourceId, 'account': account, + 'dataMimetypes': dataMimetypes, }); @override @@ -55,8 +87,23 @@ class RawContact { (other is RawContact && rawContactId == other.rawContactId && sourceId == other.sourceId && - account == other.account); + account == other.account && + _listEquals(dataMimetypes, other.dataMimetypes)); @override - int get hashCode => Object.hash(rawContactId, sourceId, account); + int get hashCode => Object.hash( + rawContactId, + sourceId, + account, + Object.hashAll(dataMimetypes), + ); + + static bool _listEquals(List a, List b) { + if (identical(a, b)) return true; + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; + } } diff --git a/lib/models/contact/contact_property.dart b/lib/models/contact/contact_property.dart index 8e30a222..82106221 100644 --- a/lib/models/contact/contact_property.dart +++ b/lib/models/contact/contact_property.dart @@ -50,6 +50,7 @@ /// | photoFullRes | ✔ | ✔ | /// | timestamp | ✔ | ⨯ | /// | identifiers | ✔ | ⨯ | +/// | dataMimetypes | ✔ | ⨯ | /// | debugData | ✔ | ⨯ | enum ContactProperty { /// Structured name property. @@ -104,6 +105,19 @@ enum ContactProperty { /// Android-specific contact identifiers (Android only). identifiers, + /// Populate `RawContact.dataMimetypes` with the `Data.MIMETYPE` values + /// present for each raw contact (Android only). Requires [identifiers] - + /// the field is exposed via `Contact.android.identifiers.rawContacts[*]`. + /// + /// Requesting [dataMimetypes] without [identifiers] throws an + /// [ArgumentError] from `get()` / `getAll()`. + /// + /// Costs one extra narrow `SELECT RAW_CONTACT_ID, MIMETYPE FROM Data` + /// query per batch. Opt in only when you need to inspect mimetypes + /// (e.g. to distinguish real telephony contacts from synthetic raw + /// contacts written by messaging apps). + dataMimetypes, + /// All data mimetypes for debugging (Android only). debugData, } diff --git a/test/api/crud_api_test.dart b/test/api/crud_api_test.dart index f62b32ea..9c8d9a4a 100644 --- a/test/api/crud_api_test.dart +++ b/test/api/crud_api_test.dart @@ -48,4 +48,41 @@ void main() { expect(results, isEmpty); expect(log.last.method, 'crud.getAll'); }); + + test( + 'getAll throws ArgumentError when dataMimetypes is requested without identifiers', + () async { + expect( + () => CrudApi.instance.getAll( + properties: {ContactProperty.dataMimetypes}, + ), + throwsArgumentError, + ); + }, + ); + + test( + 'get throws ArgumentError when dataMimetypes is requested without identifiers', + () async { + expect( + () => CrudApi.instance.get( + 'some-id', + properties: {ContactProperty.dataMimetypes}, + ), + throwsArgumentError, + ); + }, + ); + + test('getAll accepts dataMimetypes when paired with identifiers', () async { + await setUpMockMethodChannel( + methodChannel, + handler: (call) async => >[], + ); + + final results = await CrudApi.instance.getAll( + properties: {ContactProperty.identifiers, ContactProperty.dataMimetypes}, + ); + expect(results, isEmpty); + }); } diff --git a/test/models/android/raw_contact_test.dart b/test/models/android/raw_contact_test.dart new file mode 100644 index 00000000..342d7bca --- /dev/null +++ b/test/models/android/raw_contact_test.dart @@ -0,0 +1,82 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_contacts/models/accounts/account.dart'; +import 'package:flutter_contacts/models/android/raw_contact.dart'; + +void main() { + group('RawContact JSON round-trip', () { + test('decodes dataMimetypes from JSON', () { + final raw = RawContact.fromJson({ + 'rawContactId': '42', + 'sourceId': 'src-1', + 'account': {'id': '', 'name': 'user@gmail.com', 'type': 'com.google'}, + 'dataMimetypes': [ + 'vnd.android.cursor.item/phone_v2', + 'vnd.android.cursor.item/email_v2', + ], + }); + + expect(raw, isNotNull); + expect(raw!.rawContactId, '42'); + expect(raw.sourceId, 'src-1'); + expect(raw.account?.type, 'com.google'); + expect(raw.dataMimetypes, [ + 'vnd.android.cursor.item/phone_v2', + 'vnd.android.cursor.item/email_v2', + ]); + }); + + test('defaults dataMimetypes to empty when absent in JSON', () { + final raw = RawContact.fromJson({'rawContactId': '7'}); + + expect(raw, isNotNull); + expect(raw!.rawContactId, '7'); + expect(raw.dataMimetypes, isEmpty); + }); + + test('toJson omits dataMimetypes when empty', () { + const raw = RawContact(rawContactId: '7'); + expect(raw.toJson().containsKey('dataMimetypes'), isFalse); + }); + + test('toJson includes dataMimetypes when non-empty', () { + const raw = RawContact( + rawContactId: '7', + dataMimetypes: ['vnd.com.whatsapp.profile'], + ); + expect(raw.toJson()['dataMimetypes'], ['vnd.com.whatsapp.profile']); + }); + + test('fromJson returns null when no fields are populated', () { + expect(RawContact.fromJson({}), isNull); + expect(RawContact.fromJson(null), isNull); + }); + + test('fromJson returns instance when only dataMimetypes are present', () { + final raw = RawContact.fromJson({ + 'dataMimetypes': ['vnd.android.cursor.item/phone_v2'], + }); + expect(raw, isNotNull); + expect(raw!.dataMimetypes, ['vnd.android.cursor.item/phone_v2']); + }); + + test('equality and hashCode consider dataMimetypes', () { + const a = RawContact(rawContactId: '1', dataMimetypes: ['m1', 'm2']); + const b = RawContact(rawContactId: '1', dataMimetypes: ['m1', 'm2']); + const c = RawContact(rawContactId: '1', dataMimetypes: ['m1']); + + expect(a, equals(b)); + expect(a.hashCode, equals(b.hashCode)); + expect(a, isNot(equals(c))); + }); + }); + + test('Account fromJson works (sanity)', () { + final account = Account.fromJson({ + 'id': '', + 'name': 'user@gmail.com', + 'type': 'com.google', + }); + expect(account.name, 'user@gmail.com'); + expect(account.type, 'com.google'); + }); +}