1

I am trying to get all the android contacts which been updated.

I am saving on firebase the last contact id i added and the last updated timestamp

I am using the next function to get back a cursor of all the updated contacts to compare with firebase server

private fun getUpdatedContacts(): Cursor? {

    val projection = arrayOf(
            ContactsContract.Contacts._ID,
            ContactsContract.Contacts.DISPLAY_NAME,
            ContactsContract.Contacts.HAS_PHONE_NUMBER,
            ContactsContract.Contacts.CONTACT_LAST_UPDATED_TIMESTAMP)

    val selection = ContactsContract.Contacts.CONTACT_LAST_UPDATED_TIMESTAMP + " > ? AND " +
            ContactsContract.Contacts._ID + "<= ?"

    val selectionArgs = arrayOf(mFireContactDetails!!.lcu_ms.toString(), mFireContactDetails!!.lcid.toString())

    val sortOrder = ContactsContract.Contacts.CONTACT_LAST_UPDATED_TIMESTAMP + " ASC"

    return mContentResolver.query(
            ContactsContract.Contacts.CONTENT_URI,
            projection,
            selection,
            selectionArgs,
            sortOrder)
}

But when i change one contact in my phone this cursor is returned MANY un-related contacts that i never used and mark them as changed. Last time when i just added a phone number to an existing contact, i got back from this cursor more than 50 contacts as been updated.

What is going on Android?? I am trying to sync contacts for the past 3 months now. Why is it so hard???

epic
  • 1,333
  • 1
  • 13
  • 27

2 Answers2

3

This is almost the same question with the same answer as your other question: When deleting a contact on android, other random contacts id's being changed

You have some assumptions on Contact IDs that you can't make - no one guarantees Contact IDs are incremental, and no one guarantees Contact IDs are stable, in fact they are definitely not.

You can use queried contact IDs while you're app is running, there is very small chance of them being changed within some minutes, but there is some chance of having IDs changed for existing users every once in a while. Not only that, but the same ID can point to some contact today, and point to a completely different contact tomorrow.

If you keep some clone of the local contacts in the cloud, you should use the following composite IDs to reference contacts: Contacts.CONTACT_ID, Contacts.LOOKUP_KEY, Contacts.DISPLAY_NAME

See my answer here for more details: How to uniquely identify a contact on ContactsContract.Contacts table

It's not a perfect solution, but it's the best we have

marmor
  • 27,641
  • 11
  • 107
  • 150
  • Thank you for the answer, i am really struggling. It is the same code but different question. I also seen your answer but failed to see how it can help for an effective solution.If a CONTACT ID is marked with new TIME_STAMP, what are the steps to sync the contacts assuming ANYTHING and as many can change? Any solution i tried involved significant coding and high rate read/writes to firebase compared to the small changes. Clearly there must be an elegant solution for such a common requirement. Am i wrong?? – epic Jan 26 '20 at 20:11
  • don't use android's contact-ids on your firebase clone, instead make up a new random id for your contacts, and create a mapping on your android app from a local contact-id to a cloud-id. if you detect that a contact had changed its ID (e.g. unexpected name or lookup-key) use the lookupUri method to lookup the new contact-id to represent that contact (it may be still the same one), and then update the mapping only – marmor Jan 27 '20 at 13:15
  • Hi marmor, i used your advice for the HashMap but modified. if you have any time please review it. Thank you in advanced. – epic Feb 15 '20 at 14:58
0

I been testing this solution for couple of days and it seems OK but i think i need to test it much more. If you using this method, do your own testing and above all, PLEASE LET ME KNOW IF I MISSED ANYTHING and don't be hurry to downgrade. Thx!

  1. I built an App class that extents Application and implements the ActivityLifecycleCallbacks. In which i create a ContactSync class for the first time and activate it everytime the app goes to foregound
  2. In ContactSync class, i am using Kotlin withContext(Dispatchers.IO) to suspend any code for easier flow
  3. I use .get() to get all the contacts from firestore related to current user
  4. at the .get() addOnSuccessListener, i add all the contacts to a HashMap with the normalized phone number as key and name + firestore id as values (using internal class)
  5. While making the HashMap i also make sure there are no duplicates on firestore with smae phone number and if so delete them (using batch)
  6. i then retrieve all the contacts from android phone. I sort them by NORMALIZED_NUMBER first and DISPLAY_NAME (will explain later)
  7. I am now creating a batchArray with index and count to avoid exceeding the 500 limit
  8. I start scanning through the contacts cursor,
  9. I first get the normalized number, if not available (null) i create it my self using a function i made (it might be that a null value is only returned for phone numbers not in correct format, not sure)
  10. I then compare the normalized number with previous cursor value. If the same i ignore it to avoid duplicates in firestore (remember the cursor is sorted by NORMALIZED_NUMBER)
  11. I then check if the normalized number already in HashMap.
  12. If in HashMap: i compare the name in HashMap to the cursor name. if different, i conclude the name was changed and i update the firestore contact in batch array (remember to increment counter and if exceeds 500 increase index). I then remove the normalized number from the HashMap to avoid its deletion later
  13. If not in HashMap: i conclude the contact is new and i add it to firestore via batch
  14. I iterate through all the cursor until completed.
  15. When cursor complete i close it
  16. Any remaining records found in HashMap are ones that were not found on firestore hence deleted. I iterate and delete them using batch
  17. sync is done on the phone side

Now, since making the actual sync needs access to all users, i user firebase functions in node. I create 2 functions:

  1. function that fires when new user is created (signed via phone)
  2. function that fires when new contact document is created.

Both functions compare the users to the normalized number in document and if matching, writing the uid of that user to the firestore document "friend_uid" field.

Note you might have errors in these functions if you try to use them in free firebase plan. I suggest changing to Blaze plan and limit the charging to couple of dollars. By changing to Blaze, google also gives you free extras and avoid actual payment

By that, the sync is completed. The sync takes only couple of seconds

To display all the contacts which are users to the app, query all user contacts with "friend_uid" that are not null.

Some extra notes:

  1. The .get() will retrieve all the contacts every time a sync is made. That might be a lot of reads if user has couple of hundreds contacts. To minimize, i use .get(Source.DEFAULT) when launching the app and .get(Source.CACHE) for the other times. Since these documents name and number only modified by user, i believe it will not be a problem most of the times (still testing)
  2. To minimize the sync process as much as possible, i initiate it only if any contact changed its timestamp. I save the last timestamp to SharedPreferences and compare it. I found it mostly saves sync when app re-opened fast.
  3. I also save the last user logged in. If any change in user, i re-initialize the current user contacts

Some source code (still testing, please let me know if any error):

private fun getContacts(): Cursor? {
    val projection = arrayOf(
            ContactsContract.CommonDataKinds.Phone._ID,
            ContactsContract.CommonDataKinds.Phone.NUMBER,
            ContactsContract.CommonDataKinds.Phone.NORMALIZED_NUMBER,
            ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME,
            ContactsContract.CommonDataKinds.Phone.CONTACT_LAST_UPDATED_TIMESTAMP)

    //sort by NORMALIZED_NUMBER to detect duplicates and then by name to keep order and avoiding name change
    val sortOrder = ContactsContract.CommonDataKinds.Phone.NORMALIZED_NUMBER + " ASC, " +
            ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME + " ASC"

    return mContentResolver.query(
            ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
            projection,
            null,
            null,
            sortOrder)
}

    private suspend fun syncContactsAsync() = withContext(Dispatchers.IO)  {

    if (isAnythingChanged() || mFirstRun) {

        if (getValues() == Result.SUCCESS) {
            myPrintln("values retrieved success")
        } else {
            myPrintln("values retrieved failed. Aborting.")
            return@withContext
        }

        val cursor: Cursor? = getContacts()

        if (cursor == null) {
            myPrintln("cursor cannot be null")
            mFireContactHashMap.clear()
            return@withContext
        }

        if (cursor.count == 0) {
            cursor.close()
            mFireContactHashMap.clear()
            myPrintln("cursor empty")
            return@withContext
        }

        var contactName: String?
        var internalContact: InternalContact?
        val batchArray = mutableListOf(FirebaseFirestore.getInstance().batch())
        var batchIndex = 0
        var batchCount = 0
        var normalizedNumber:String?
        var prevNumber = ""
        var firestoreId: String

        while (cursor.moveToNext()) {

            normalizedNumber = cursor.getString(COLUMN_UPDATED_NORMALIZED_NUMBER)

            if (normalizedNumber == null) {
                normalizedNumber = cursor.getString(COLUMN_UPDATED_PHONE_NUMBER)
                normalizedNumber = Phone.getParsedPhoneNumber(mDeviceCountryIso,normalizedNumber,mContext)
            }

            //cursor sorted by normalized numbers so if same as previous, do not check
            if (normalizedNumber != prevNumber) {

                prevNumber = normalizedNumber

                contactName = cursor.getString(COLUMN_UPDATED_DISPLAY_NAME)
                internalContact = mFireContactHashMap[normalizedNumber]

                //if phone number exists on firestore
                if (internalContact != null) {

                    //if name changed, update in firestore
                    if (internalContact.name != contactName) {
                        myPrintln("updating $normalizedNumber from name: ${internalContact.name} to: $contactName")
                        batchArray[batchIndex].update(
                                mFireContactRef.document(internalContact.id),
                                FireContact.COLUMN_NAME,
                                contactName)

                        batchCount++
                    }

                    //remove to avoid deletions
                    mFireContactHashMap.remove(normalizedNumber)
                } else {
                    //New item. Insert
                    if (normalizedNumber != mUserPhoneNumber) {
                        myPrintln("adding $normalizedNumber / $contactName")
                        firestoreId = mFireContactRef.document().id

                        batchArray[batchIndex].set(mFireContactRef.document(firestoreId),
                                FireContact(firestoreId, -1, contactName,
                                        cursor.getString(COLUMN_UPDATED_PHONE_NUMBER),
                                        normalizedNumber))
                        batchCount++
                    }
                }

                if (BATCH_HALF_MAX < batchCount ) {

                    batchArray += FirebaseFirestore.getInstance().batch()
                    batchCount = 0
                    batchIndex++
                }
            }
        }

        cursor.close()

        //Remaining contacts not found on cursor so assumed deleted. Delete from firestore
        mFireContactHashMap.forEach { (key, value) ->
            myPrintln("deleting ${value.name} / $key")
            batchArray[batchIndex].delete(mFireContactRef.document(value.id))
            batchCount++
            if (BATCH_HALF_MAX < batchCount ) {
                batchArray += FirebaseFirestore.getInstance().batch()
                batchCount = 0
                batchIndex++
            }
        }

        //execute all batches

        if ((batchCount > 0) || (batchIndex > 0)) {
            myPrintln("committing changes...")
            batchArray.forEach { batch ->
                batch.commit()
            }
        } else {
            myPrintln("no records to commit")
        }

        myPrintln("end sync")


        mFireContactHashMap.clear()
        mPreferenceManager.edit().putLong(PREF_LAST_TIMESTAMP,mLastContactUpdated).apply()

        mFirstRun = false
    } else {
        myPrintln("no change in contacts")
    }
}

private suspend fun putAllUserContactsToHashMap() : Result {

    var result = Result.FAILED

    val batchArray = mutableListOf(FirebaseFirestore.getInstance().batch())
    var batchIndex = 0
    var batchCount = 0

    mFireContactHashMap.clear()

    var source = Source.CACHE

    if (mFirstRun) {
        source = Source.DEFAULT
        myPrintln("get contacts via Source.DEFAULT")
    } else {
        myPrintln("get contacts via Source.CACHE")
    }


    mFireContactRef.whereEqualTo( FireContact.COLUMN_USER_ID,mUid ).get(source)
    .addOnSuccessListener {documents ->

        var fireContact : FireContact

        for (doc in documents) {

            fireContact = doc.toObject(FireContact::class.java)

            if (!mFireContactHashMap.containsKey(fireContact.paPho)) {
                mFireContactHashMap[fireContact.paPho] = InternalContact(fireContact.na, doc.id)
            } else {
                myPrintln("duplicate will be removed from firestore: ${fireContact.paPho} / ${fireContact.na} / ${doc.id}")

                batchArray[batchIndex].delete(mFireContactRef.document(doc.id))

                batchCount++

                if (BATCH_HALF_MAX < batchCount) {
                    batchArray += FirebaseFirestore.getInstance().batch()
                    batchCount = 0
                    batchIndex++
                }
            }
        }

        result = Result.SUCCESS
    }.addOnFailureListener { exception ->
        myPrintln("Error getting documents: $exception")
    }.await()

    //execute all batches
    if ((batchCount > 0) || (batchIndex > 0)) {
        myPrintln("committing duplicate delete... ")
        batchArray.forEach { batch ->
            batch.commit()
        }
    } else {
        myPrintln("no duplicates to delete")
    }

    return result
}
epic
  • 1,333
  • 1
  • 13
  • 27