android/build.gradle | 2 +- .../kotlin/co/quis/flutter_contacts/Contact.kt | 15 ++ .../co/quis/flutter_contacts/FlutterContacts.kt | 203 +++++++++++++++------ .../quis/flutter_contacts/FlutterContactsPlugin.kt | 8 +- example/lib/main.dart | 4 +- example_full/lib/pages/contact_page.dart | 2 +- lib/contact.dart | 32 +++- lib/flutter_contacts.dart | 37 ++-- 8 files changed, 229 insertions(+), 74 deletions(-) diff --git a/android/build.gradle b/android/build.gradle index a136b6a..7e74abf 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -31,7 +31,7 @@ android { main.java.srcDirs += 'src/main/kotlin' } defaultConfig { - minSdkVersion 16 + minSdkVersion 18 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } lintOptions { diff --git a/android/src/main/kotlin/co/quis/flutter_contacts/Contact.kt b/android/src/main/kotlin/co/quis/flutter_contacts/Contact.kt index bac4c04..efe7f29 100644 --- a/android/src/main/kotlin/co/quis/flutter_contacts/Contact.kt +++ b/android/src/main/kotlin/co/quis/flutter_contacts/Contact.kt @@ -13,6 +13,11 @@ import co.quis.flutter_contacts.properties.Website data class Contact( var id: String, + var rawId: String? = null, + var lookupKey: String? = null, + var lastModified: String? = null, + var sourceId: String? = null, + var deletedTimestamp: String? = null, var displayName: String, var thumbnail: ByteArray? = null, var photo: ByteArray? = null, @@ -31,6 +36,11 @@ data class Contact( fun fromMap(m: Map): Contact { return Contact( m["id"] as String, + m["rawId"] as? String, + m["lookupKey"] as? String, + m["lastModified"] as? String, + m["sourceId"] as? String, + m["deletedTimestamp"] as? String, m["displayName"] as String, m["thumbnail"] as? ByteArray, m["photo"] as? ByteArray, @@ -50,6 +60,11 @@ data class Contact( fun toMap(): Map = mapOf( "id" to id, + "rawId" to rawId, + "lookupKey" to lookupKey, + "lastModified" to lastModified, + "sourceId" to sourceId, + "deletedTimestamp" to deletedTimestamp, "displayName" to displayName, "thumbnail" to thumbnail, "photo" to photo, diff --git a/android/src/main/kotlin/co/quis/flutter_contacts/FlutterContacts.kt b/android/src/main/kotlin/co/quis/flutter_contacts/FlutterContacts.kt index d2e5a3d..51ac38e 100644 --- a/android/src/main/kotlin/co/quis/flutter_contacts/FlutterContacts.kt +++ b/android/src/main/kotlin/co/quis/flutter_contacts/FlutterContacts.kt @@ -3,24 +3,22 @@ package co.quis.flutter_contacts import android.content.ContentProviderOperation import android.content.ContentResolver import android.content.ContentUris +import android.content.ContentValues import android.content.res.AssetFileDescriptor import android.database.Cursor import android.net.Uri import android.provider.ContactsContract +import android.provider.ContactsContract.* +import android.provider.ContactsContract.CommonDataKinds.* import android.provider.ContactsContract.CommonDataKinds.Email import android.provider.ContactsContract.CommonDataKinds.Event -import android.provider.ContactsContract.CommonDataKinds.Im -import android.provider.ContactsContract.CommonDataKinds.Nickname import android.provider.ContactsContract.CommonDataKinds.Note import android.provider.ContactsContract.CommonDataKinds.Organization import android.provider.ContactsContract.CommonDataKinds.Phone -import android.provider.ContactsContract.CommonDataKinds.Photo -import android.provider.ContactsContract.CommonDataKinds.StructuredName -import android.provider.ContactsContract.CommonDataKinds.StructuredPostal import android.provider.ContactsContract.CommonDataKinds.Website -import android.provider.ContactsContract.Contacts -import android.provider.ContactsContract.Data -import android.provider.ContactsContract.RawContacts +import java.io.FileNotFoundException +import java.io.InputStream +import java.io.OutputStream import co.quis.flutter_contacts.properties.Account as PAccount import co.quis.flutter_contacts.properties.Address as PAddress import co.quis.flutter_contacts.properties.Email as PEmail @@ -31,9 +29,6 @@ import co.quis.flutter_contacts.properties.Organization as POrganization import co.quis.flutter_contacts.properties.Phone as PPhone import co.quis.flutter_contacts.properties.SocialMedia as PSocialMedia import co.quis.flutter_contacts.properties.Website as PWebsite -import java.io.FileNotFoundException -import java.io.InputStream -import java.io.OutputStream class FlutterContacts { companion object { @@ -48,12 +43,14 @@ class FlutterContacts { withPhoto: Boolean, returnUnifiedContacts: Boolean, includeNonVisible: Boolean, - idIsRawContactId: Boolean = false + idIsRawContactId: Boolean = false, + accountType: String?, + accountName: String?, ): List> { if (id == null && !withProperties && !withThumbnail && !withPhoto && returnUnifiedContacts ) { - return getQuick(resolver, includeNonVisible) + return getQuick(resolver, includeNonVisible, accountType, accountName) } // All fields we care about – ID and display name are always included. @@ -112,11 +109,14 @@ class FlutterContacts { Event.START_DATE, Event.TYPE, Event.LABEL, - Note.NOTE + Note.NOTE, + Contacts.LOOKUP_KEY, + RawContacts.SOURCE_ID, + Contacts.CONTACT_LAST_UPDATED_TIMESTAMP ) ) } - if (withProperties || !returnUnifiedContacts) { + if (withProperties || (accountType != null && accountName != null) || !returnUnifiedContacts) { projection.addAll( listOf( Data.RAW_CONTACT_ID, @@ -127,22 +127,31 @@ class FlutterContacts { } var selectionClauses = mutableListOf() - if (!includeNonVisible) { - // This drops contacts not part of any group. - // See: https://stackoverflow.com/questions/28665587/what-does-contactscontract-contacts-in-visible-group-mean-in-android - selectionClauses.add("${Data.IN_VISIBLE_GROUP} = 1") - } - var selectionArgs = arrayOf() - + var selectionArgs = mutableListOf() if (id != null) { - if (idIsRawContactId || !returnUnifiedContacts) { - selectionClauses.add("${Data.RAW_CONTACT_ID} = ?") + if (idIsRawContactId) { + selectionClauses.add("(${Data.RAW_CONTACT_ID} = ?)") } else { - selectionClauses.add("${Data.CONTACT_ID} = ?") + selectionClauses.add(" (${Data.CONTACT_ID} = ?)") + } + selectionArgs.add(id) + } + + if (includeNonVisible) { + if (accountType != null && accountName != null) { + selectionClauses.add("(${RawContacts.ACCOUNT_TYPE} = ? AND ${RawContacts.ACCOUNT_NAME} = ?)") + selectionArgs.add(accountType) + selectionArgs.add(accountName) + } + } else { + // This drops contacts not part of any group. + // See: https://stackoverflow.com/questions/28665587/what-does-contactscontract-contacts-in-visible-group-mean-in-android + if (accountType != null && accountName != null) { + selectionClauses.add("((${Data.IN_VISIBLE_GROUP} = 1) OR (${RawContacts.ACCOUNT_TYPE} = ? AND ${RawContacts.ACCOUNT_NAME} = ?))") + selectionArgs.add(accountType) + selectionArgs.add(accountName) } - selectionArgs = arrayOf(id) } - val selection: String? = if (selectionClauses.isEmpty()) null else selectionClauses.joinToString(separator = " AND ") // NOTE: The projection filters columns, and the selection filters rows. We // could filter rows to those with requested MIME types, but it introduces a @@ -150,11 +159,13 @@ class FlutterContacts { // instead loop through all rows and filter them in Kotlin. // Query contact database. + val selection: String? = if (selectionClauses.isEmpty()) null else selectionClauses.joinToString(separator = " AND ") + val selectionArgsArray = if (selectionArgs.isEmpty()) null else selectionArgs.toTypedArray() val cursor = resolver.query( Data.CONTENT_URI, projection.toTypedArray(), selection, - selectionArgs, + selectionArgsArray, /*sortOrder=*/null ) @@ -172,11 +183,12 @@ class FlutterContacts { while (cursor.moveToNext()) { // ID and display name. - val id = if (returnUnifiedContacts) getString(Data.CONTACT_ID) else getString(Data.RAW_CONTACT_ID) + val id = getString(Data.RAW_CONTACT_ID) if (id !in index) { var contact = Contact( - /*id=*/id, - /*displayName=*/getString(Contacts.DISPLAY_NAME_PRIMARY) + getString(Data.CONTACT_ID), getString(Data.RAW_CONTACT_ID), getString(Data.LOOKUP_KEY), + getString(Data.CONTACT_LAST_UPDATED_TIMESTAMP), getString(RawContacts.SOURCE_ID), null, + getString(Contacts.DISPLAY_NAME_PRIMARY) ) // Fetch high-resolution photo if requested. @@ -392,6 +404,8 @@ class FlutterContacts { // option enabled. // // If an account is provided, use it explicitly instead. + var accountType: String? = null + var accountName: String? = null if (contact.accounts.isEmpty()) { ops.add( ContentProviderOperation.newInsert(RawContacts.CONTENT_URI) @@ -400,6 +414,8 @@ class FlutterContacts { .build() ) } else { + accountType = contact.accounts.first().type + accountName = contact.accounts.first().name ops.add( ContentProviderOperation.newInsert(RawContacts.CONTENT_URI) .withValue(RawContacts.ACCOUNT_TYPE, contact.accounts.first().type) @@ -428,7 +444,7 @@ class FlutterContacts { resolver, rawId.toString(), /*with_properties=*/ true, /*with_thumbnail=*/true, /*withPhoto=*/true, /*returnUnifiedContacts=*/true, - /*includeNonVisible=*/true, /*idIsRawContactId=*/true + /*includeNonVisible=*/true, /*idIsRawContactId=*/true, accountType, accountName ) if (insertedContacts.isEmpty()) { @@ -494,11 +510,21 @@ class FlutterContacts { // Load contacts with that raw ID, which will give us the full contact as it // was saved. + var accountType: String? = null + var accountName: String? = null + if (contact.accounts.isNotEmpty()) { + accountType = contact.accounts.first().type + accountName = contact.accounts.first().name + } + if(contact.sourceId != null) { + updateSourceId(resolver, contact) + } + val updatedContacts: List> = select( resolver, contactId, /*with_properties=*/ true, /*with_thumbnail=*/true, /*withPhoto=*/true, /*returnUnifiedContacts=*/true, - /*includeNonVisible=*/true, /*idIsRawContactId=*/false + /*includeNonVisible=*/true, /*idIsRawContactId=*/true, accountType, accountName ) if (updatedContacts.isEmpty()) { @@ -524,37 +550,100 @@ class FlutterContacts { // getQuick is like `select(id = null, withProperties = false, // withThumbnail = false, withPhoto = false)` but much faster (100 ms vs 400 ms // on a Pixel 3 with 600 contacts). - private fun getQuick(resolver: ContentResolver, includeNonVisible: Boolean): List> { + private fun getQuick(resolver: ContentResolver, includeNonVisible: Boolean, accountType: String?, accountName: String?): List> { // This drops contacts not part of any group. // See: https://stackoverflow.com/questions/28665587/what-does-contactscontract-contacts-in-visible-group-mean-in-android - val selection: String? = if (includeNonVisible) null else "${Data.IN_VISIBLE_GROUP} = 1" + // Deleted contacts // Query contact database. - val cursor = resolver.query( - Contacts.CONTENT_URI, - /*projection=*/null, - selection, - /*selectionArgs=*/null, - /*sortOrder=*/null + val cursorDeletedContacts = resolver.query( + ContactsContract.DeletedContacts.CONTENT_URI, + null, + null, + null, + null ) - - // List of all contacts. - var contacts = mutableListOf() - if (cursor == null) { - return listOf() + var deletedContacts = mutableMapOf() + if (cursorDeletedContacts != null) { + while (cursorDeletedContacts.moveToNext()) { + val deletedContactId = (cursorDeletedContacts.getString(cursorDeletedContacts.getColumnIndex(ContactsContract.DeletedContacts.CONTACT_ID)) ?: ""); + val deletedContactTimestamp = (cursorDeletedContacts.getString(cursorDeletedContacts.getColumnIndex(ContactsContract.DeletedContacts.CONTACT_DELETED_TIMESTAMP)) ?: ""); + deletedContacts.put(deletedContactId, deletedContactTimestamp) + } + } + cursorDeletedContacts.close() + + // Visible contacts + var modifiedContacts = mutableMapOf() + // Query contact database for unified contacts (Contacts.CONTENT_URI). + val cursorVisibleContacts = resolver.query( + Contacts.CONTENT_URI, + /*projection=*/null, + null, + /*selectionArgs=*/null, + /*sortOrder=*/null + ) + // List of all visible contacts. + var visibleContacts = mutableListOf() + if (cursorVisibleContacts != null) { + while (cursorVisibleContacts.moveToNext()) { + var contactId = (cursorVisibleContacts.getString(cursorVisibleContacts.getColumnIndex(Contacts._ID)) ?: "") + var contactLookupKey = (cursorVisibleContacts.getString(cursorVisibleContacts.getColumnIndex(Contacts.LOOKUP_KEY)) ?: "") + var contactLastUpdatedTimestamp = (cursorVisibleContacts.getString(cursorVisibleContacts.getColumnIndex(Contacts.CONTACT_LAST_UPDATED_TIMESTAMP)) ?: "") + var deletedTimestamp: String? = deletedContacts[contactId] + var contactDisplayName = (cursorVisibleContacts.getString(cursorVisibleContacts.getColumnIndex(Contacts.DISPLAY_NAME_PRIMARY)) ?: "") + modifiedContacts.put(contactId, contactLastUpdatedTimestamp) + val visible = (cursorVisibleContacts.getString(cursorVisibleContacts.getColumnIndex(Data.IN_VISIBLE_GROUP))) + if(visible == "1") { + visibleContacts.add( + Contact( + /*id=*/contactId, + null, contactLookupKey, contactLastUpdatedTimestamp, null, deletedTimestamp, + /*displayName=*/contactDisplayName + ) + ) + } + } + cursorVisibleContacts.close() } - while (cursor.moveToNext()) { - contacts.add( - Contact( - /*id=*/(cursor.getString(cursor.getColumnIndex(Contacts._ID)) ?: ""), - /*displayName=*/(cursor.getString(cursor.getColumnIndex(Contacts.DISPLAY_NAME_PRIMARY)) ?: "") - ) + // Contacts for account + if (accountType != null && accountName != null) { + val rawContactUri = RawContacts.CONTENT_URI.buildUpon() + .appendQueryParameter(RawContacts.ACCOUNT_TYPE, accountType) + .appendQueryParameter(RawContacts.ACCOUNT_NAME, accountName) + .build() + val cursorContacts4account = resolver.query( + rawContactUri, + null, + null, + null, + null ) + if (cursorContacts4account != null) { + while (cursorContacts4account.moveToNext()) { + var contactId = (cursorContacts4account.getString(cursorContacts4account.getColumnIndex(RawContacts.CONTACT_ID)) ?: "") + var rawContactId = (cursorContacts4account.getString(cursorContacts4account.getColumnIndex(RawContacts._ID)) ?: "") + var lastModified: String? = null + if(contactId != null) { + lastModified = modifiedContacts[contactId] + } + var sourceId = (cursorContacts4account.getString(cursorContacts4account.getColumnIndex(RawContacts.SOURCE_ID)) ?: "") + var deletedTimestamp: String? = deletedContacts[rawContactId] + var contactDisplayName = (cursorContacts4account.getString(cursorContacts4account.getColumnIndex(Contacts.DISPLAY_NAME_PRIMARY)) ?: "") + visibleContacts.add( + Contact( + contactId, + rawContactId, null, lastModified, sourceId, deletedTimestamp, contactDisplayName + ) + ) + } + cursorContacts4account.close() + } } - cursor.close() - return contacts.map { it.toMap() } + + return visibleContacts.map { it.toMap() } } private fun getPhoneLabel(cursor: Cursor): String { @@ -934,5 +1023,11 @@ class FlutterContacts { fd.close() } } + + private fun updateSourceId(resolver: ContentResolver, contact: Contact) { + var contentValues = ContentValues() + contentValues.put(RawContacts.SOURCE_ID, contact.sourceId) + resolver.update(RawContacts.CONTENT_URI, contentValues, "${RawContacts._ID} = ?", arrayOf(contact.rawId)) + } } } diff --git a/android/src/main/kotlin/co/quis/flutter_contacts/FlutterContactsPlugin.kt b/android/src/main/kotlin/co/quis/flutter_contacts/FlutterContactsPlugin.kt index 16870bf..5365984 100644 --- a/android/src/main/kotlin/co/quis/flutter_contacts/FlutterContactsPlugin.kt +++ b/android/src/main/kotlin/co/quis/flutter_contacts/FlutterContactsPlugin.kt @@ -118,11 +118,17 @@ public class FlutterContactsPlugin : FlutterPlugin, MethodCallHandler, EventChan val returnUnifiedContacts = args[4] as Boolean val includeNonVisible = args[5] as Boolean // args[6] = includeNotesOnIos13AndAbove + var accountType: String? = null + var accountName: String? = null + if(args.size >=9) { + accountType = args[7] as String? + accountName = args[8] as String? + } val contacts: List> = FlutterContacts.select( resolver!!, id, withProperties, withThumbnail, withPhoto, - returnUnifiedContacts, includeNonVisible + returnUnifiedContacts, includeNonVisible, false, accountType, accountName ) GlobalScope.launch(Dispatchers.Main) { result.success(contacts) } } diff --git a/example/lib/main.dart b/example/lib/main.dart index bf9378c..ed9406c 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -43,8 +43,8 @@ class _FlutterContactsExampleState extends State { onTap: () async { final fullContact = await FlutterContacts.getContact(_contacts[i].id); - await Navigator.of(context).push( - MaterialPageRoute(builder: (_) => ContactPage(fullContact))); + await Navigator.of(context).push(MaterialPageRoute( + builder: (_) => ContactPage(fullContact.first))); })); } } diff --git a/example_full/lib/pages/contact_page.dart b/example_full/lib/pages/contact_page.dart index d6ae419..1b74ebb 100644 --- a/example_full/lib/pages/contact_page.dart +++ b/example_full/lib/pages/contact_page.dart @@ -34,7 +34,7 @@ class _ContactPageState extends State final contact = await FlutterContacts.getContact(_contact.id, withThumbnail: !highRes, withPhoto: highRes); setState(() { - _contact = contact; + _contact = contact.first; }); } diff --git a/lib/contact.dart b/lib/contact.dart index 345ee60..1be8aab 100644 --- a/lib/contact.dart +++ b/lib/contact.dart @@ -3,7 +3,6 @@ import 'dart:math'; import 'dart:typed_data'; import 'package:flutter_contacts/config.dart'; -import 'package:flutter_contacts/vcard.dart'; import 'package:flutter_contacts/flutter_contacts.dart'; import 'package:flutter_contacts/properties/account.dart'; import 'package:flutter_contacts/properties/address.dart'; @@ -15,6 +14,7 @@ import 'package:flutter_contacts/properties/organization.dart'; import 'package:flutter_contacts/properties/phone.dart'; import 'package:flutter_contacts/properties/social_media.dart'; import 'package:flutter_contacts/properties/website.dart'; +import 'package:flutter_contacts/vcard.dart'; /// A contact. /// @@ -75,6 +75,21 @@ class Contact { /// The unique identifier of the contact. String id; + /// The raw id of the contact + String rawId; + + /// The lookup key for an unified contact in case the contact id changed (android only ?) + String lookupKey; + + /// Timestamp of last modification + String lastModified; + + /// A server side id of the contact + String sourceId; + + /// Timestamp of the deletion (android: in case a SyncAdapter has not finally deleted the contact) + String deletedTimestamp; + /// The contact display name. String displayName; @@ -131,6 +146,11 @@ class Contact { Contact({ this.id = '', + this.rawId, + this.lookupKey, + this.lastModified, + this.sourceId, + this.deletedTimestamp, this.displayName = '', this.thumbnail, this.photo, @@ -157,6 +177,11 @@ class Contact { factory Contact.fromJson(Map json) => Contact( id: json['id'] as String, + rawId: json['rawId'] as String, + lookupKey: json['lookupKey'] as String, + lastModified: json['lastModified'] as String, + sourceId: json['sourceId'] as String, + deletedTimestamp: json['deletedTimestamp'] as String, displayName: json['displayName'] as String, thumbnail: json['thumbnail'] as Uint8List, photo: json['photo'] as Uint8List, @@ -196,6 +221,11 @@ class Contact { }) => Map.from({ 'id': id, + 'rawId': rawId, + 'lookupKey': lookupKey, + 'lastModified': lastModified, + 'sourceId': sourceId, + 'deletedTimestamp': deletedTimestamp, 'displayName': displayName, 'thumbnail': withThumbnail ? thumbnail : null, 'photo': withPhoto ? photo : null, diff --git a/lib/flutter_contacts.dart b/lib/flutter_contacts.dart index 8a92c00..4ee87f2 100644 --- a/lib/flutter_contacts.dart +++ b/lib/flutter_contacts.dart @@ -58,6 +58,8 @@ class FlutterContacts { bool withPhoto = false, bool sorted = true, bool deduplicateProperties = true, + String accountType, + String accountName, }) async => _select( withProperties: withProperties, @@ -65,9 +67,11 @@ class FlutterContacts { withPhoto: withPhoto, sorted: sorted, deduplicateProperties: deduplicateProperties, + accountType: accountType, + accountName: accountName, ); - /// Fetches one contact. + /// Fetches one contact with its assigned contacts. /// /// By default everything available is fetched. If [withProperties] is /// false, properties (phones, emails, addresses, websites, etc) won't be @@ -80,24 +84,25 @@ class FlutterContacts { /// If [deduplicateProperties] is true, the properties will be de-duplicated, /// mainly to avoid the case (common on Android) where multiple equivalent /// phones are returned. - static Future getContact( + static Future> getContact( String id, { bool withProperties = true, bool withThumbnail = true, bool withPhoto = true, bool deduplicateProperties = true, - }) async { - final contacts = await _select( - id: id, - withProperties: withProperties, - withThumbnail: withThumbnail, - withPhoto: withPhoto, - sorted: false, - deduplicateProperties: deduplicateProperties, - ); - if (contacts.length != 1) return null; - return contacts.first; - } + String accountType, + String accountName, + }) async => + _select( + id: id, + withProperties: withProperties, + withThumbnail: withThumbnail, + withPhoto: withPhoto, + sorted: false, + deduplicateProperties: deduplicateProperties, + accountType: accountType, + accountName: accountName, + ); /// Inserts a new [contact] in the database and returns it. /// @@ -215,6 +220,8 @@ class FlutterContacts { bool withPhoto = false, bool sorted = true, bool deduplicateProperties = true, + String accountType, + String accountName, }) async { // removing the types makes it crash at runtime // ignore: omit_local_variable_types @@ -226,6 +233,8 @@ class FlutterContacts { config.returnUnifiedContacts, config.includeNonVisibleOnAndroid, config.includeNotesOnIos13AndAbove, + accountType, + accountName, ]); // ignore: omit_local_variable_types List contacts = untypedContacts