A Guide to Tracking Recently Modified Contacts on Mobile

Photo by freestocks / Unsplash

In this blog, we will explore the process of fetching recently added, updated, and deleted contacts from the native side in a Flutter application. Our goal is to provide a seamless way to access recent contacts, enhancing the user experience by allowing users to quickly reach their most important connections. By eliminating the need for comparing contacts with stored data, we can directly retrieve the most recently added or modified contacts. This approach ensures that users always have the latest contact information at their fingertips, fetched efficiently and effectively.

What Is Recent Contact Retrieval?

Recent contact retrieval refers to the process of identifying and retrieving contacts that have been recently created, updated, or deleted on Android and iOS devices by leveraging their respective native APIs. This process focuses on utilizing platform-specific capabilities to bypass manual data comparison and directly access recent contact changes.

  • Android: By using the native ContactsContract API, the focus is on querying the device's contact database to retrieve contacts based on their creation, modification, or deletion timestamps.
  • iOS: Utilizing the Contacts framework (e.g., CNContactStore, CNChangeHistoryFetchRequest), this approach involves accessing the contact change history, including recently added, updated, and deleted contacts, without storing previous states or comparing old data.

The goal is to streamline the process of fetching the latest contact data efficiently and effectively, allowing apps to access real-time contact updates directly from the native APIs.

Let's start the implementation

Prerequisites

Android: Add the required permissions in AndroidManifest.xml (e.g., READ_CONTACTS).

iOS: Add permissions in Info.plist (e.g., NSContactsUsageDescription).

Here’s the adjusted content with a proper title and formatted for clarity:

Setting Up Permissions and Method Channels for Contact Access

To successfully fetch contacts in a Flutter application, follow these steps:

  1. Add Required Permissions: Ensure that you add the necessary permissions in both Android and iOS to access the contacts.
  2. Create Method Channel: Set up a method channel for native communication in both Android and iOS. This channel will handle communication between Flutter and the native side to retrieve the data.
  3. Implement Native Code: Write the required native code for both Android and iOS to perform the necessary operations.
  4. Retrieve Contacts: Use the method channel to retrieve contacts from the native side.
  5. Display Contacts: Finally, display the retrieved contacts on the Flutter side.

Note: It’s essential to request contact permission from the user before processing any actions related to contacts.

iOS implementation

iOS does not directly provide timestamps for contacts due to privacy concerns. Instead, it offers a class called CNChangeHistoryFetchRequest, which retrieves a list of contacts based on a starting token from the change history. Initially, this starting token is set to , allowing the retrieval of all contacts. To optimize future retrievals, it’s essential to store the starting token locally, enabling access only to contacts added or modified after the token was last updated.

Additionally, iOS provides CNChangeHistoryEvent, which includes information about contacts that have been deleted, updated, or added during the last active session of the app.

Each available data field in contacts is associated with different keys, allowing for precise data extraction. The method changeHistoryFetchResult is used to obtain recently modified, added, or deleted contacts based on the starting token. However, this method is not directly supported in Swift. To access it, you need to create a bridge header to write the necessary code in Objective-C, which can then be called from Swift.

To access the change history of contacts in Swift, you can use the following Objective-C code. First, you need to import the required Objective-C class in your bridge header file. This allows you to leverage the functionality provided by Objective-C in your Swift project.

ContactStoreWrapper.h

#ifndef ContactStoreWrapper_h
#define ContactStoreWrapper_h


#endif /* ContactStoreWrapper_h */


// ContactStoreWrapper.h
#import <Foundation/Foundation.h>

NS_ASSUME_NONNULL_BEGIN

@class CNContactStore;
@class CNChangeHistoryFetchRequest;
@class CNFetchResult;

@interface ContactStoreWrapper : NSObject
- (instancetype)initWithStore:(CNContactStore *)store NS_DESIGNATED_INITIALIZER;

- (CNFetchResult *)changeHistoryFetchResult:(CNChangeHistoryFetchRequest *)request
                                      error:(NSError *__autoreleasing  _Nullable * _Nullable)error;

@end

NS_ASSUME_NONNULL_END

ContactStoreWrapper.m

#import "ContactStoreWrapper.h"
@import Contacts;

@interface ContactStoreWrapper ()
@property (nonatomic, strong) CNContactStore *store;
@end
@implementation ContactStoreWrapper

- (instancetype)init {
    return [self initWithStore:[[CNContactStore alloc] init]];
}
- (instancetype)initWithStore:(CNContactStore *)store {
    if (self = [super init]) {
        _store = store;
    }
    return self;
}

- (CNFetchResult *)changeHistoryFetchResult:(CNChangeHistoryFetchRequest *)request
                                      error:(NSError *__autoreleasing  _Nullable * _Nullable)error  API_AVAILABLE(ios(13.0)){
    CNFetchResult *fetchResult = [self.store enumeratorForChangeHistoryFetchRequest:request error:error];
    return fetchResult;
}
@end

Method for fetching the change history

    /// This method will fetched recently modified or created contact
    /// First time it will fetched all the contacts and save the 
  ///startingToken and again if user open the app it will fetch the contact ///duration between last open application
  
    @available(iOS 13.0, *)
    func fetchChanges() async -> [[String: Any?]] {
        let fetchHistoryRequest = CNChangeHistoryFetchRequest()
        let store = CNContactStore()
        fetchHistoryRequest.startingToken = savedToken
        fetchHistoryRequest.additionalContactKeyDescriptors = 
       [CNContactFormatter.descriptorForRequiredKeys(for: .fullName)                CNContactPhoneNumbersKey as CNKeyDescriptor]
        let wrapper = ContactStoreWrapper(store: store)
        var contacts: [[String: Any?]] = []
        await withCheckedContinuation { continuation in
            DispatchQueue.global(qos: .userInitiated).async {
                let result = 
          wrapper.changeHistoryFetchResult(fetchHistoryRequest, error: nil)
      // Saving the result's token as stated in CNContactStore documentation
                Task { @MainActor in
                    self.savedToken = result.currentHistoryToken
                }
        guard let enumerator = result.value as? NSEnumerator else { return }
                enumerator
                    .compactMap { $0 as? CNChangeHistoryEvent }
                    .forEach { event in
                        if let addEvent = event as? 
           CNChangeHistoryAddContactEvent {
                            // Provides added contact here..
                        } else if let updateEvent = event as? 
           CNChangeHistoryUpdateContactEvent {
                            // Provides updated contact here..
                        } else if let deletedEvent = event as? 
           CNChangeHistoryDeleteContactEvent {
                            // Provides deleted contact here..
                        }
                    }
                continuation.resume()
            }
        }
        return contacts
    }
    
    
private var savedToken: Data? {
  get {
      UserDefaults.standard.data(forKey: savedTokenUserDefaultsKey)
      }
  set {
    UserDefaults.standard.set(newValue, forKey:  savedTokenUserDefaultsKey)
      }
     }

By using the above code, you can fetch the latest changed, updated, or deleted contacts with ease, without any extra effort.

Android implementation

In Android, we can fetch contacts using ContactsContract and ContentResolver.query. This allows us to access all types of contacts (added, updated, or deleted). Additionally, we can retrieve contact history over any duration, which is not possible in iOS. In Android, we fetch contacts by comparing their last modified timestamps, something that iOS does not provide.

To achieve similar behavior in Android, we store the app's open timestamp in shared preferences, eliminating the need to pass a timestamp when fetching contacts. When the app is opened for the first time, it will fetch all contacts by default, using a base timestamp of January 1, 1970. After all contacts are fetched, the timestamp will be updated to the current time. The next time the app is opened, it will fetch contacts created between the last stored timestamp and the current time, so we need to pass this timestamp from Flutter or any other platform.

During the first launch, we may receive multiple deleted contacts from the device's contact database. This is something we cannot handle initially, but we can adjust for it in a real-time application since there will be no need for recent contacts right after the app is installed. From the second launch onward, the system works as expected.


private fun getContacts(context: Context, contentResolver: ContentResolver): List<Map<String, Any?>> {
val contacts = mutableListOf<Map<String, Any?>>()
al sharedPreferences = context.getSharedPreferences("MyPrefs", Context.MODE_PRIVATE)
val savedTimestamp = sharedPreferences.getLong("lastFetchedTimestamp", 0L) 
val currentTimestamp = System.currentTimeMillis()
val uri = ContactsContract.Contacts.CONTENT_URI
val selection = "${ContactsContract.Contacts.CONTACT_LAST_UPDATED_TIMESTAMP} > ?"
        val selectionArgs = arrayOf(savedTimestamp.toString())
        val cursor = contentResolver.query(uri, null, selection, selectionArgs, null)

cursor?.use {
val contactIdIndex = it.getColumnIndex(ContactsContract.Contacts._ID)
            val displayNameIndex = it.getColumnIndex(ContactsContract.Contacts.DISPLAY_NAME)
            val lastUpdatedIndex = it.getColumnIndex(ContactsContract.Contacts.CONTACT_LAST_UPDATED_TIMESTAMP)

if (contactIdIndex >= 0 && displayNameIndex >= 0 && lastUpdatedIndex >= 0) {
                while (it.moveToNext()) {
                    val contactId = it.getString(contactIdIndex)
                    val lastUpdated = it.getLong(lastUpdatedIndex)

                if (lastUpdated > savedTimestamp) {
  /// You can use contactId here or store contacts object by using all keys
                    }
                }
            }
        }

  // Fetch deleted contacts
val deletedContactsUri = ContactsContract.DeletedContacts.CONTENT_URI
        val deletedCursor = contentResolver.query(deletedContactsUri, null, null, null, null)
deletedCursor?.use {
val contactIdIndex = it.getColumnIndex(ContactsContract.DeletedContacts.CONTACT_ID)
val timestampIndex = it.getColumnIndex(ContactsContract.DeletedContacts.CONTACT_DELETED_TIMESTAMP)
            if (contactIdIndex >= 0 && timestampIndex >= 0) {
                while (it.moveToNext()) {
                  val contactId = it.getString(contactIdIndex) ?: "deleted"
                    val lastUpdated = it.getLong(timestampIndex)

                    if (lastUpdated > savedTimestamp) {
                        /// You can get deleted contactId here..
                    }
                }
            }
        }

        // Save the new timestamp
        with(sharedPreferences.edit()) {
            putLong("lastFetchedTimestamp", currentTimestamp)
            apply()
        }

        return contacts
    }

You can call these methods through the respective method channels and return the contact list as a response to your Flutter app.

Here are some screenshots from a demo application. Initially, it provides all contacts, but after updating any contact and restarting the application, it will only display the updated contact. Additionally, various contact data can be accessed, as shown in the alert.

Conclusion

By using native APIs in Android and iOS, you can efficiently retrieve recently added, updated, or deleted contacts in your Flutter app. Implementing these solutions through method channels ensures smooth communication between the native and Flutter layers, providing real-time access to contact changes and enhancing the user experience.

Reference URL

TN3149: Fetching Contacts change history events | Apple Developer Documentation
Learn how to fetch and process the most recent changes to the Contacts database.
CNChangeHistoryFetchRequest | Apple Developer Documentation
An object that specifies the criteria for fetching change history.
Contacts Provider | Identity | Android Developers