Merge "Support bulk capability exchange in UCE"
diff --git a/src/java/com/android/ims/rcs/uce/eab/EabBulkCapabilityUpdater.java b/src/java/com/android/ims/rcs/uce/eab/EabBulkCapabilityUpdater.java
new file mode 100644
index 0000000..7083631
--- /dev/null
+++ b/src/java/com/android/ims/rcs/uce/eab/EabBulkCapabilityUpdater.java
@@ -0,0 +1,483 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.ims.rcs.uce.eab;
+
+import static android.telephony.ims.RcsContactUceCapability.CAPABILITY_MECHANISM_OPTIONS;
+import static android.telephony.ims.RcsContactUceCapability.CAPABILITY_MECHANISM_PRESENCE;
+
+import static com.android.ims.rcs.uce.eab.EabControllerImpl.getCapabilityCacheExpiration;
+
+import android.app.AlarmManager;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.database.ContentObserver;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Handler;
+import android.os.IBinder;
+import android.os.Looper;
+import android.os.PersistableBundle;
+import android.os.RemoteException;
+import android.provider.ContactsContract;
+import android.provider.Telephony;
+import android.telephony.CarrierConfigManager;
+import android.telephony.ims.ImsManager;
+import android.telephony.ims.ImsRcsManager;
+import android.telephony.ims.RcsContactUceCapability;
+import android.telephony.ims.aidl.IRcsUceControllerCallback;
+import android.util.Log;
+
+import com.android.ims.rcs.uce.UceController;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public final class EabBulkCapabilityUpdater {
+    private final String TAG = this.getClass().getSimpleName();
+
+    private static final Uri USER_EAB_SETTING = Uri.withAppendedPath(Telephony.SimInfo.CONTENT_URI,
+            Telephony.SimInfo.COLUMN_IMS_RCS_UCE_ENABLED);
+    private static final int NUM_SECS_IN_DAY = 86400;
+
+    private final int mSubId;
+    private final Context mContext;
+    private final Handler mHandler;
+
+    private final AlarmManager.OnAlarmListener mCapabilityExpiredListener;
+    private final ContactChangedListener mContactProviderListener;
+    private final EabSettingsListener mEabSettingListener;
+    private final BroadcastReceiver mCarrierConfigChangedListener;
+    private final EabControllerImpl mEabControllerImpl;
+    private final EabContactSyncController mEabContactSyncController;
+
+    private UceController.UceControllerCallback mUceControllerCallback;
+    private List<Uri> mRefreshContactList;
+
+    private boolean mIsContactProviderListenerRegistered = false;
+    private boolean mIsEabSettingListenerRegistered = false;
+    private boolean mIsCarrierConfigListenerRegistered = false;
+    private boolean mIsCarrierConfigEnabled = false;
+
+    /**
+     * Listen capability expired intent. Only registered when
+     * {@link CarrierConfigManager.Ims#KEY_RCS_BULK_CAPABILITY_EXCHANGE_BOOL} has enabled bulk
+     * capability exchange.
+     */
+    private class CapabilityExpiredListener implements AlarmManager.OnAlarmListener {
+        @Override
+        public void onAlarm() {
+            Log.d(TAG, "Capability expired.");
+            try {
+                List<Uri> expiredContactList = getExpiredContactList();
+                if (expiredContactList.size() > 0) {
+                    mUceControllerCallback.refreshCapabilities(
+                            getExpiredContactList(),
+                            mRcsUceControllerCallback);
+                } else {
+                    Log.d(TAG, "expiredContactList is empty.");
+                }
+            } catch (RemoteException e) {
+                Log.e(TAG, "CapabilityExpiredListener RemoteException", e);
+            }
+        }
+    }
+
+    /**
+     * Listen contact provider change. Only registered when
+     * {@link CarrierConfigManager.Ims#KEY_RCS_BULK_CAPABILITY_EXCHANGE_BOOL} has enabled bulk
+     * capability exchange.
+     */
+    private class ContactChangedListener extends ContentObserver {
+        public ContactChangedListener(Handler handler) {
+            super(handler);
+        }
+
+        @Override
+        public void onChange(boolean selfChange) {
+            Log.d(TAG, "Contact changed");
+            syncContactAndRefreshCapabilities();
+        }
+    }
+
+    /**
+     * Listen EAB settings change. Only registered when
+     * {@link CarrierConfigManager.Ims#KEY_RCS_BULK_CAPABILITY_EXCHANGE_BOOL} has enabled bulk
+     * capability exchange.
+     */
+    private class EabSettingsListener extends ContentObserver {
+        public EabSettingsListener(Handler handler) {
+            super(handler);
+        }
+
+        @Override
+        public void onChange(boolean selfChange) {
+            boolean isUserEnableUce = isUserEnableUce();
+            Log.d(TAG, "EAB user setting changed: " + isUserEnableUce);
+            if (isUserEnableUce) {
+                mHandler.post(new SyncContactRunnable());
+            } else {
+                unRegisterContactProviderListener();
+                cancelTimeAlert(mContext);
+            }
+        }
+    }
+
+    /**
+     * Listen carrier config changed to prevent this instance created before carrier config loaded.
+     */
+    private class CarrierConfigChangedListener extends BroadcastReceiver {
+        @Override
+        public void onReceive(Context context, Intent intent) {
+            boolean isSupportBulkCapabilityExchange = getBooleanCarrierConfig(
+                CarrierConfigManager.Ims.KEY_RCS_BULK_CAPABILITY_EXCHANGE_BOOL, mSubId);
+
+            Log.d(TAG, "Carrier config changed. "
+                    + "isCarrierConfigEnabled: " + mIsCarrierConfigEnabled
+                    + ", isSupportBulkCapabilityExchange: " + isSupportBulkCapabilityExchange);
+            if (!mIsCarrierConfigEnabled && isSupportBulkCapabilityExchange) {
+                enableBulkCapability();
+                updateExpiredTimeAlert();
+                mIsCarrierConfigEnabled = true;
+            } else if (mIsCarrierConfigEnabled && !isSupportBulkCapabilityExchange) {
+                onDestroy();
+            }
+        }
+    }
+
+    private IRcsUceControllerCallback mRcsUceControllerCallback = new IRcsUceControllerCallback() {
+        @Override
+        public void onCapabilitiesReceived(List<RcsContactUceCapability> contactCapabilities) {
+            Log.d(TAG, "onCapabilitiesReceived");
+            mEabControllerImpl.saveCapabilities(contactCapabilities);
+        }
+
+        @Override
+        public void onComplete() {
+            Log.d(TAG, "onComplete");
+        }
+
+        @Override
+        public void onError(int errorCode, long retryAfterMilliseconds) {
+            Log.d(TAG, "Refresh capabilities failed. Error code: " + errorCode
+                    + ", retryAfterMilliseconds: " + retryAfterMilliseconds);
+            if (retryAfterMilliseconds != 0) {
+                mHandler.postDelayed(new retryRunnable(), retryAfterMilliseconds);
+            }
+        }
+
+        @Override
+        public IBinder asBinder() {
+            return null;
+        }
+    };
+
+    private class SyncContactRunnable implements Runnable {
+        @Override
+        public void run() {
+            Log.d(TAG, "Sync contact from contact provider");
+            syncContactAndRefreshCapabilities();
+            registerContactProviderListener();
+            registerEabUserSettingsListener();
+        }
+    }
+
+    /**
+     * Re-refresh capability if error happened.
+     */
+    private class retryRunnable implements Runnable {
+        @Override
+        public void run() {
+            Log.d(TAG, "Retry refreshCapabilities()");
+
+            try {
+                mUceControllerCallback.refreshCapabilities(
+                        mRefreshContactList, mRcsUceControllerCallback);
+            } catch (RemoteException e) {
+                Log.e(TAG, "refreshCapabilities RemoteException" , e);
+            }
+        }
+    }
+
+    public EabBulkCapabilityUpdater(Context context,
+            int subId,
+            EabControllerImpl eabControllerImpl,
+            EabContactSyncController eabContactSyncController,
+            UceController.UceControllerCallback uceControllerCallback,
+            Looper looper) {
+        mContext = context;
+        mSubId = subId;
+        mEabControllerImpl = eabControllerImpl;
+        mEabContactSyncController = eabContactSyncController;
+        mUceControllerCallback = uceControllerCallback;
+
+        mHandler = new Handler(looper);
+        mContactProviderListener = new ContactChangedListener(mHandler);
+        mEabSettingListener = new EabSettingsListener(mHandler);
+        mCapabilityExpiredListener = new CapabilityExpiredListener();
+        mCarrierConfigChangedListener = new CarrierConfigChangedListener();
+
+        Log.d(TAG, "create EabBulkCapabilityUpdater() subId: " + mSubId);
+
+        enableBulkCapability();
+        updateExpiredTimeAlert();
+    }
+
+    private void enableBulkCapability() {
+        boolean isUserEnableUce = isUserEnableUce();
+        boolean isSupportBulkCapabilityExchange = getBooleanCarrierConfig(
+                CarrierConfigManager.Ims.KEY_RCS_BULK_CAPABILITY_EXCHANGE_BOOL, mSubId);
+
+        Log.d(TAG, "isUserEnableUce: " + isUserEnableUce
+                + ", isSupportBulkCapabilityExchange: " + isSupportBulkCapabilityExchange);
+
+        if (isUserEnableUce && isSupportBulkCapabilityExchange) {
+            mHandler.post(new SyncContactRunnable());
+            mIsCarrierConfigEnabled = true;
+        } else if (!isUserEnableUce && isSupportBulkCapabilityExchange) {
+            registerEabUserSettingsListener();
+            mIsCarrierConfigEnabled = false;
+        } else {
+            registerCarrierConfigChanged();
+            Log.d(TAG, "Not support bulk capability exchange.");
+        }
+    }
+
+    private void syncContactAndRefreshCapabilities() {
+        mRefreshContactList = mEabContactSyncController.syncContactToEabProvider(mContext);
+        Log.d(TAG, "refresh contacts number: " + mRefreshContactList.size());
+
+        if (mUceControllerCallback == null) {
+            Log.d(TAG, "mUceControllerCallback is null.");
+            return;
+        }
+
+        try {
+            if (mRefreshContactList.size() > 0) {
+                mUceControllerCallback.refreshCapabilities(
+                        mRefreshContactList, mRcsUceControllerCallback);
+            }
+        } catch (RemoteException e) {
+            Log.e(TAG, "mUceControllerCallback RemoteException.", e);
+        }
+    }
+
+    protected void updateExpiredTimeAlert() {
+        boolean isUserEnableUce = isUserEnableUce();
+        boolean isSupportBulkCapabilityExchange = getBooleanCarrierConfig(
+                CarrierConfigManager.Ims.KEY_RCS_BULK_CAPABILITY_EXCHANGE_BOOL, mSubId);
+
+        Log.d(TAG, " updateExpiredTimeAlert(), isUserEnableUce: " + isUserEnableUce
+                + ", isSupportBulkCapabilityExchange: " + isSupportBulkCapabilityExchange);
+
+        if (isUserEnableUce && isSupportBulkCapabilityExchange) {
+            long expiredTimestamp = getLeastExpiredTimestamp();
+            if (expiredTimestamp == Long.MAX_VALUE) {
+                Log.d(TAG, "Can't find min timestamp in eab provider");
+                return;
+            }
+            expiredTimestamp += getCapabilityCacheExpiration(mSubId);
+            Log.d(TAG, "set time alert at " + expiredTimestamp);
+            cancelTimeAlert(mContext);
+            setTimeAlert(mContext, expiredTimestamp);
+        }
+    }
+
+    private long getLeastExpiredTimestamp() {
+        String selection = "("
+                // Query presence timestamp
+                + EabProvider.EabCommonColumns.MECHANISM + "=" + CAPABILITY_MECHANISM_PRESENCE
+                + " AND "
+                + EabProvider.PresenceTupleColumns.REQUEST_TIMESTAMP + " IS NOT NULL) "
+
+                // Query options timestamp
+                + " OR " + "(" + EabProvider.EabCommonColumns.MECHANISM + "="
+                + CAPABILITY_MECHANISM_OPTIONS + " AND "
+                + EabProvider.OptionsColumns.REQUEST_TIMESTAMP + " IS NOT NULL) "
+
+                // filter by sub id
+                + " AND " + EabProvider.EabCommonColumns.SUBSCRIPTION_ID + "=" + mSubId
+
+                // filter the contact that not come from contact provider
+                + " AND " + EabProvider.ContactColumns.RAW_CONTACT_ID + " IS NOT NULL "
+                + " AND " + EabProvider.ContactColumns.DATA_ID + " IS NOT NULL ";
+
+        long minTimestamp = Long.MAX_VALUE;
+        Cursor result = mContext.getContentResolver().query(EabProvider.ALL_DATA_URI, null,
+                selection,
+                null, null);
+
+        if (result != null) {
+            while (result.moveToNext()) {
+                int mechanism = result.getInt(
+                        result.getColumnIndex(EabProvider.EabCommonColumns.MECHANISM));
+                long timestamp;
+                if (mechanism == CAPABILITY_MECHANISM_PRESENCE) {
+                    timestamp = result.getLong(result.getColumnIndex(
+                            EabProvider.PresenceTupleColumns.REQUEST_TIMESTAMP));
+                } else {
+                    timestamp = result.getLong(result.getColumnIndex(
+                            EabProvider.OptionsColumns.REQUEST_TIMESTAMP));
+                }
+
+                if (timestamp < minTimestamp) {
+                    minTimestamp = timestamp;
+                }
+            }
+            result.close();
+        } else {
+            Log.d(TAG, "getLeastExpiredTimestamp() cursor is null");
+        }
+        return minTimestamp;
+    }
+
+    private void setTimeAlert(Context context, long wakeupTimeMs) {
+        AlarmManager am = context.getSystemService(AlarmManager.class);
+
+        // To prevent all devices from sending requests to the server at the same time, add a jitter
+        // time (0 sec ~ 2 days) randomly.
+        int jitterTimeSec = (int) (Math.random() * (NUM_SECS_IN_DAY * 2));
+        Log.d(TAG, " setTimeAlert: " + wakeupTimeMs + ", jitterTimeSec: " + jitterTimeSec);
+        am.set(AlarmManager.RTC_WAKEUP,
+                (wakeupTimeMs * 1000) + jitterTimeSec,
+                TAG,
+                mCapabilityExpiredListener,
+                mHandler);
+    }
+
+    private void cancelTimeAlert(Context context) {
+        Log.d(TAG, "cancelTimeAlert.");
+        AlarmManager am = context.getSystemService(AlarmManager.class);
+        am.cancel(mCapabilityExpiredListener);
+    }
+
+    private boolean getBooleanCarrierConfig(String key, int subId) {
+        CarrierConfigManager mConfigManager = mContext.getSystemService(CarrierConfigManager.class);
+        PersistableBundle b = null;
+        if (mConfigManager != null) {
+            b = mConfigManager.getConfigForSubId(subId);
+        }
+        if (b != null) {
+            return b.getBoolean(key);
+        } else {
+            Log.w(TAG, "getConfigForSubId(subId) is null. Return the default value of " + key);
+            return CarrierConfigManager.getDefaultConfig().getBoolean(key);
+        }
+    }
+
+    private boolean isUserEnableUce() {
+        ImsManager manager = mContext.getSystemService(ImsManager.class);
+        if (manager == null) {
+            Log.e(TAG, "ImsManager is null");
+            return false;
+        }
+        try {
+            ImsRcsManager rcsManager = manager.getImsRcsManager(mSubId);
+            return (rcsManager != null) && rcsManager.getUceAdapter().isUceSettingEnabled();
+        } catch (Exception e) {
+            Log.e(TAG, "hasUserEnabledUce: exception = " + e.getMessage());
+        }
+        return false;
+    }
+
+    private List<Uri> getExpiredContactList() {
+        List<Uri> refreshList = new ArrayList<>();
+        long expiredTime = (System.currentTimeMillis() / 1000)
+                + getCapabilityCacheExpiration(mSubId);
+        String selection = "("
+                + EabProvider.EabCommonColumns.MECHANISM + "=" + CAPABILITY_MECHANISM_PRESENCE
+                + " AND " + EabProvider.PresenceTupleColumns.REQUEST_TIMESTAMP + "<"
+                + expiredTime + ")";
+        selection += " OR " + "(" + EabProvider.EabCommonColumns.MECHANISM + "="
+                + CAPABILITY_MECHANISM_OPTIONS + " AND "
+                + EabProvider.OptionsColumns.REQUEST_TIMESTAMP + "<" + expiredTime + ")";
+
+        Cursor result = mContext.getContentResolver().query(EabProvider.ALL_DATA_URI, null,
+                selection,
+                null, null);
+        while (result.moveToNext()) {
+            String phoneNumber = result.getString(
+                    result.getColumnIndex(EabProvider.ContactColumns.PHONE_NUMBER));
+            refreshList.add(Uri.parse(phoneNumber));
+        }
+        result.close();
+        return refreshList;
+    }
+
+    protected void onDestroy() {
+        Log.d(TAG, "onDestroy");
+        cancelTimeAlert(mContext);
+        unRegisterContactProviderListener();
+        unRegisterEabUserSettings();
+        unRegisterCarrierConfigChanged();
+    }
+
+    private void registerContactProviderListener() {
+        Log.d(TAG, "registerContactProviderListener");
+        mIsContactProviderListenerRegistered = true;
+        mContext.getContentResolver().registerContentObserver(
+                ContactsContract.Contacts.CONTENT_URI,
+                true,
+                mContactProviderListener);
+    }
+
+    private void registerEabUserSettingsListener() {
+        Log.d(TAG, "registerEabUserSettingsListener");
+        mIsEabSettingListenerRegistered = true;
+        mContext.getContentResolver().registerContentObserver(
+                USER_EAB_SETTING,
+                true,
+                mEabSettingListener);
+    }
+
+    private void registerCarrierConfigChanged() {
+        Log.d(TAG, "registerCarrierConfigChanged");
+        mIsCarrierConfigListenerRegistered = true;
+        IntentFilter FILTER_CARRIER_CONFIG_CHANGED =
+                new IntentFilter(CarrierConfigManager.ACTION_CARRIER_CONFIG_CHANGED);
+        mContext.registerReceiver(mCarrierConfigChangedListener, FILTER_CARRIER_CONFIG_CHANGED);
+    }
+
+    private void unRegisterContactProviderListener() {
+        Log.d(TAG, "unRegisterContactProviderListener");
+        if (mIsContactProviderListenerRegistered) {
+            mIsContactProviderListenerRegistered = false;
+            mContext.getContentResolver().unregisterContentObserver(mContactProviderListener);
+        }
+    }
+
+    private void unRegisterEabUserSettings() {
+        Log.d(TAG, "unRegisterEabUserSettings");
+        if (mIsEabSettingListenerRegistered) {
+            mIsEabSettingListenerRegistered = false;
+            mContext.getContentResolver().unregisterContentObserver(mEabSettingListener);
+        }
+    }
+
+    private void unRegisterCarrierConfigChanged() {
+        Log.d(TAG, "unregisterCarrierConfigChanged");
+        if (mIsCarrierConfigListenerRegistered) {
+            mIsCarrierConfigListenerRegistered = false;
+            mContext.unregisterReceiver(mCarrierConfigChangedListener);
+        }
+    }
+
+    public void setUceRequestCallback(UceController.UceControllerCallback uceControllerCallback) {
+        mUceControllerCallback = uceControllerCallback;
+    }
+}
diff --git a/src/java/com/android/ims/rcs/uce/eab/EabContactSyncController.java b/src/java/com/android/ims/rcs/uce/eab/EabContactSyncController.java
new file mode 100644
index 0000000..e9bb9ec
--- /dev/null
+++ b/src/java/com/android/ims/rcs/uce/eab/EabContactSyncController.java
@@ -0,0 +1,332 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.ims.rcs.uce.eab;
+
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.database.Cursor;
+import android.database.DatabaseUtils;
+import android.net.Uri;
+import android.preference.PreferenceManager;
+import android.provider.ContactsContract;
+import android.util.Log;
+
+import com.android.internal.annotations.VisibleForTesting;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * Sync the contacts from Contact Provider to EAB Provider
+ */
+public class EabContactSyncController {
+    private final String TAG = this.getClass().getSimpleName();
+
+    private static final int NOT_INIT_LAST_UPDATED_TIME = -1;
+    private static final String LAST_UPDATED_TIME_KEY = "eab_last_updated_time";
+
+    /**
+     * Sync contact from Contact provider to EAB provider. There are 4 kinds of cases need to be
+     * handled when received the contact db changed:
+     *
+     * 1. Contact deleted
+     * 2. Delete the phone number in the contact
+     * 3. Update the phone number
+     * 4. Add a new contact and add phone number
+     *
+     * @return The contacts that need to refresh
+     */
+    @VisibleForTesting
+    public List<Uri> syncContactToEabProvider(Context context) {
+        Log.d(TAG, "syncContactToEabProvider");
+        List<Uri> refreshContacts = null;
+        StringBuilder selection = new StringBuilder();
+        String[] selectionArgs = null;
+
+        // Get the last update timestamp from shared preference.
+        long lastUpdatedTimeStamp = getLastUpdatedTime(context);
+        if (lastUpdatedTimeStamp != -1) {
+            selection.append(ContactsContract.Contacts.CONTACT_LAST_UPDATED_TIMESTAMP + ">?");
+            selectionArgs = new String[]{String.valueOf(lastUpdatedTimeStamp)};
+        }
+
+        // Contact deleted cases (case 1)
+        handleContactDeletedCase(context, lastUpdatedTimeStamp);
+
+        // Query the contacts that have not been synchronized to eab contact table.
+        Cursor updatedContact = context.getContentResolver().query(
+                ContactsContract.Data.CONTENT_URI,
+                null,
+                selection.toString(),
+                selectionArgs,
+                null);
+
+        if (updatedContact != null) {
+            Log.d(TAG, "Contact changed count: " + updatedContact.getCount());
+
+            // Delete the EAB phone number that not in contact provider (case 2). Updated phone
+            // number(case 3) also delete in here and re-insert in next step.
+            handlePhoneNumberDeletedCase(context, updatedContact);
+
+            // Insert the phone number that not in EAB provider (case 3 and case 4)
+            refreshContacts = handlePhoneNumberInsertedCase(context, updatedContact);
+
+            // Update the last update time in shared preference
+            if (updatedContact.getCount() > 0) {
+                long maxTimestamp = findMaxTimestamp(updatedContact);
+                if (maxTimestamp != Long.MIN_VALUE) {
+                    setLastUpdatedTime(context, maxTimestamp);
+                }
+            }
+            updatedContact.close();
+        } else {
+            Log.e(TAG, "Cursor is null.");
+        }
+        return refreshContacts;
+    }
+
+    /**
+     * Delete the phone numbers that contact has been deleted in contact provider. Query based on
+     * {@link ContactsContract.DeletedContacts#CONTENT_URI} to know which contact has been removed.
+     *
+     * @param timeStamp last updated timestamp
+     */
+    private void handleContactDeletedCase(Context context, long timeStamp) {
+        String selection = "";
+        if (timeStamp != -1) {
+            selection =
+                    ContactsContract.DeletedContacts.CONTACT_DELETED_TIMESTAMP + ">" + timeStamp;
+        }
+
+        Cursor cursor = context.getContentResolver().query(
+                ContactsContract.DeletedContacts.CONTENT_URI,
+                new String[]{ContactsContract.DeletedContacts.CONTACT_ID,
+                        ContactsContract.DeletedContacts.CONTACT_DELETED_TIMESTAMP},
+                selection,
+                null,
+                null);
+
+        if (cursor == null) {
+            Log.d(TAG, "handleContactDeletedCase() cursor is null.");
+            return;
+        }
+
+        Log.d(TAG, "(Case 1) The count of contact that need to be deleted: "
+                + cursor.getCount());
+
+        StringBuilder deleteClause = new StringBuilder();
+        while (cursor.moveToNext()) {
+            if (deleteClause.length() > 0) {
+                deleteClause.append(" OR ");
+            }
+
+            String contactId = cursor.getString(cursor.getColumnIndex(
+                    ContactsContract.DeletedContacts.CONTACT_ID));
+            deleteClause.append(EabProvider.ContactColumns.CONTACT_ID + "=" + contactId);
+        }
+
+        if (deleteClause.toString().length() > 0) {
+            int number = context.getContentResolver().delete(
+                    EabProvider.CONTACT_URI,
+                    deleteClause.toString(),
+                    null);
+            Log.d(TAG, "(Case 1) Deleted contact count=" + number);
+        }
+    }
+
+    /**
+     * Delete phone numbers that have been deleted in the contact provider. There is no API to get
+     * deleted phone numbers easily, so check all updated contact's phone number and delete the
+     * phone number. It will also delete the phone number that has been changed.
+     */
+    private void handlePhoneNumberDeletedCase(Context context, Cursor cursor) {
+        // The map represent which contacts have which numbers.
+        Map<String, List<String>> phoneNumberMap = new HashMap<>();
+        cursor.moveToPosition(-1);
+        while (cursor.moveToNext()) {
+            String rawContactId = cursor.getString(
+                    cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.RAW_CONTACT_ID));
+            String number = cursor.getString(
+                    cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER));
+
+            if (phoneNumberMap.containsKey(rawContactId)) {
+                phoneNumberMap.get(rawContactId).add(number);
+            } else {
+                List<String> phoneNumberList = new ArrayList<>();
+                phoneNumberList.add(number);
+                phoneNumberMap.put(rawContactId, phoneNumberList);
+            }
+        }
+
+        // Build a SQL statement that delete the phone number not exist in contact provider.
+        // For example:
+        // raw_contact_id = 1 AND phone_number NOT IN (12345, 23456)
+        StringBuilder deleteClause = new StringBuilder();
+        List<String> deleteClauseArgs = new ArrayList<>();
+        for (Map.Entry<String, List<String>> entry : phoneNumberMap.entrySet()) {
+            String rawContactId = entry.getKey();
+            List<String> phoneNumberList = entry.getValue();
+
+            if (deleteClause.length() > 0) {
+                deleteClause.append(" OR ");
+            }
+
+            deleteClause.append("(" + EabProvider.ContactColumns.RAW_CONTACT_ID + "=? ");
+            deleteClauseArgs.add(rawContactId);
+
+            if (phoneNumberList.size() > 0) {
+                String argsList = phoneNumberList.stream()
+                        .map(s -> "?")
+                        .collect(Collectors.joining(", "));
+                deleteClause.append(" AND "
+                        + EabProvider.ContactColumns.PHONE_NUMBER
+                        + " NOT IN (" + argsList + "))");
+                deleteClauseArgs.addAll(phoneNumberList);
+            } else {
+                deleteClause.append(")");
+            }
+        }
+
+        if (deleteClause.length() > 1) {
+            int number = context.getContentResolver().delete(
+                    EabProvider.CONTACT_URI,
+                    deleteClause.toString(),
+                    deleteClauseArgs.toArray(new String[0]));
+            Log.d(TAG, "(Case 2, 3) handlePhoneNumberDeletedCase number count= " + number);
+        } else {
+            Log.d(TAG, "(Case 2, 3) handlePhoneNumberDeletedCase number count is empty.");
+        }
+    }
+
+    /**
+     * Insert new phone number.
+     *
+     * @param contactCursor the result of updated contact
+     * @return the contacts that need to refresh
+     */
+    private List<Uri> handlePhoneNumberInsertedCase(Context context,
+            Cursor contactCursor) {
+        List<Uri> refreshContacts = new ArrayList<>();
+        List<ContentValues> allContactData = new ArrayList<>();
+        contactCursor.moveToPosition(-1);
+
+        // Query all of contacts that store in eab provider
+        Cursor eabContact = context.getContentResolver().query(
+                EabProvider.CONTACT_URI,
+                null,
+                EabProvider.ContactColumns.DATA_ID + " IS NOT NULL",
+                null,
+                EabProvider.ContactColumns.DATA_ID);
+
+        while (contactCursor.moveToNext()) {
+            String contactId = contactCursor.getString(contactCursor.getColumnIndex(
+                    ContactsContract.Data.CONTACT_ID));
+            String rawContactId = contactCursor.getString(contactCursor.getColumnIndex(
+                    ContactsContract.CommonDataKinds.Phone.RAW_CONTACT_ID));
+            String dataId = contactCursor.getString(
+                    contactCursor.getColumnIndex(ContactsContract.Data._ID));
+            String number = contactCursor.getString(
+                    contactCursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER));
+            String mimeType = contactCursor.getString(
+                    contactCursor.getColumnIndex(ContactsContract.Data.MIMETYPE));
+
+
+            if (!mimeType.equals(ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE)) {
+                continue;
+            }
+
+            int index = searchDataIdIndex(eabContact, Integer.parseInt(dataId));
+            if (index == -1) {
+                Log.d(TAG, "Data id does not exist. Insert phone number into EAB db.");
+                refreshContacts.add(Uri.parse(number));
+                ContentValues data = new ContentValues();
+                data.put(EabProvider.ContactColumns.CONTACT_ID, contactId);
+                data.put(EabProvider.ContactColumns.DATA_ID, dataId);
+                data.put(EabProvider.ContactColumns.RAW_CONTACT_ID, rawContactId);
+                data.put(EabProvider.ContactColumns.PHONE_NUMBER, number);
+                allContactData.add(data);
+            }
+        }
+
+        // Insert contacts at once
+        int result = context.getContentResolver().bulkInsert(
+                EabProvider.CONTACT_URI,
+                allContactData.toArray(new ContentValues[0]));
+        Log.d(TAG, "(Case 3, 4) Phone number insert count: " + result);
+        return refreshContacts;
+    }
+
+    /**
+     * Binary search the target data_id in the cursor.
+     *
+     * @param cursor       EabProvider contact which sorted by
+     *                     {@link EabProvider.ContactColumns#DATA_ID}
+     * @param targetDataId the data_id to search for
+     * @return the index of cursor
+     */
+    private int searchDataIdIndex(Cursor cursor, int targetDataId) {
+        int start = 0;
+        int end = cursor.getCount() - 1;
+
+        while (start <= end) {
+            int position = (start + end) >>> 1;
+            cursor.moveToPosition(position);
+            int dataId = cursor.getInt(cursor.getColumnIndex(EabProvider.ContactColumns.DATA_ID));
+
+            if (dataId > targetDataId) {
+                end = position - 1;
+            } else if (dataId < targetDataId) {
+                start = position + 1;
+            } else {
+                return position;
+            }
+        }
+        return -1;
+    }
+
+
+    private long findMaxTimestamp(Cursor cursor) {
+        long maxTimestamp = Long.MIN_VALUE;
+        cursor.moveToPosition(-1);
+        while(cursor.moveToNext()) {
+            long lastUpdatedTimeStamp = cursor.getLong(cursor.getColumnIndex(
+                    ContactsContract.CommonDataKinds.Phone.CONTACT_LAST_UPDATED_TIMESTAMP));
+            Log.d(TAG, lastUpdatedTimeStamp + " " + maxTimestamp);
+            if (lastUpdatedTimeStamp > maxTimestamp) {
+                maxTimestamp = lastUpdatedTimeStamp;
+            }
+        }
+        return maxTimestamp;
+    }
+
+    private void setLastUpdatedTime(Context context, long timestamp) {
+        Log.d(TAG, "setLastUpdatedTime: " + timestamp);
+        SharedPreferences sharedPreferences =
+                PreferenceManager.getDefaultSharedPreferences(context);
+        sharedPreferences.edit().putLong(LAST_UPDATED_TIME_KEY, timestamp).apply();
+    }
+
+    private long getLastUpdatedTime(Context context) {
+        SharedPreferences sharedPreferences =
+                PreferenceManager.getDefaultSharedPreferences(context);
+        return sharedPreferences.getLong(LAST_UPDATED_TIME_KEY, NOT_INIT_LAST_UPDATED_TIME);
+    }
+}
diff --git a/src/java/com/android/ims/rcs/uce/eab/EabControllerImpl.java b/src/java/com/android/ims/rcs/uce/eab/EabControllerImpl.java
index 27c4218..0cffaf9 100644
--- a/src/java/com/android/ims/rcs/uce/eab/EabControllerImpl.java
+++ b/src/java/com/android/ims/rcs/uce/eab/EabControllerImpl.java
@@ -57,15 +57,20 @@
 
     private final Context mContext;
     private final int mSubId;
+    private final EabBulkCapabilityUpdater mEabBulkCapabilityUpdater;
+
     private UceControllerCallback mUceControllerCallback;
-    private final Looper mLooper;
     private volatile boolean mIsSetDestroyedFlag = false;
 
     public EabControllerImpl(Context context, int subId, UceControllerCallback c, Looper looper) {
         mContext = context;
         mSubId = subId;
         mUceControllerCallback = c;
-        mLooper = looper;
+        mEabBulkCapabilityUpdater = new EabBulkCapabilityUpdater(mContext, mSubId,
+                this,
+                new EabContactSyncController(),
+                mUceControllerCallback,
+                looper);
     }
 
     @Override
@@ -80,6 +85,7 @@
     public void onDestroy() {
         Log.d(TAG, "onDestroy");
         mIsSetDestroyedFlag = true;
+        mEabBulkCapabilityUpdater.onDestroy();
     }
 
     /**
@@ -93,6 +99,7 @@
             return;
         }
         mUceControllerCallback = c;
+        mEabBulkCapabilityUpdater.setUceRequestCallback(c);
     }
 
     /**
@@ -148,7 +155,8 @@
         for (RcsContactUceCapability capability : contactCapabilities) {
             String phoneNumber = getNumberFromUri(capability.getContactUri());
             Cursor c = mContext.getContentResolver().query(
-                    EabProvider.CONTACT_URI, null, EabProvider.ContactColumns.PHONE_NUMBER + "=?",
+                    EabProvider.CONTACT_URI, null,
+                    EabProvider.ContactColumns.PHONE_NUMBER + "=?",
                     new String[]{phoneNumber}, null);
 
             if (c != null && c.moveToNext()) {
@@ -176,6 +184,8 @@
                 c.close();
             }
         }
+
+        mEabBulkCapabilityUpdater.updateExpiredTimeAlert();
     }
 
     private List<EabCapabilityResult> generateDestroyedResult(List<Uri> contactUri) {
@@ -335,7 +345,7 @@
         if (requestTimeStamp != null) {
             Instant expiredTimestamp = Instant
                     .ofEpochSecond(Long.parseLong(requestTimeStamp))
-                    .plus(getCapabilityCacheExpiration(), ChronoUnit.SECONDS);
+                    .plus(getCapabilityCacheExpiration(mSubId), ChronoUnit.SECONDS);
             expired = expiredTimestamp.isBefore(Instant.now());
             Log.d(TAG, "Capability expiredTimestamp: "
                     + expiredTimestamp.getEpochSecond() + ", expired:" + expired);
@@ -352,7 +362,7 @@
         if (requestTimeStamp != null) {
             Instant expiredTimestamp = Instant
                     .ofEpochSecond(Long.parseLong(requestTimeStamp))
-                    .plus(getAvailabilityCacheExpiration(), ChronoUnit.SECONDS);
+                    .plus(getAvailabilityCacheExpiration(mSubId), ChronoUnit.SECONDS);
             expired = expiredTimestamp.isBefore(Instant.now());
             Log.d(TAG, "Availability insertedTimestamp: "
                     + expiredTimestamp.getEpochSecond() + ", expired:" + expired);
@@ -375,10 +385,10 @@
         return expiredTimestamp;
     }
 
-    private long getCapabilityCacheExpiration() {
+    protected static long getCapabilityCacheExpiration(int subId) {
         long value = -1;
         try {
-            ProvisioningManager pm = ProvisioningManager.createForSubscriptionId(mSubId);
+            ProvisioningManager pm = ProvisioningManager.createForSubscriptionId(subId);
             value = pm.getProvisioningIntValue(
                     ProvisioningManager.KEY_RCS_CAPABILITIES_CACHE_EXPIRATION_SEC);
         } catch (Exception ex) {
@@ -392,10 +402,10 @@
         return value;
     }
 
-    private long getAvailabilityCacheExpiration() {
+    protected static long getAvailabilityCacheExpiration(int subId) {
         long value = -1;
         try {
-            ProvisioningManager pm = ProvisioningManager.createForSubscriptionId(mSubId);
+            ProvisioningManager pm = ProvisioningManager.createForSubscriptionId(subId);
             value = pm.getProvisioningIntValue(
                     ProvisioningManager.KEY_RCS_AVAILABILITY_CACHE_EXPIRATION_SEC);
         } catch (Exception ex) {
@@ -424,10 +434,13 @@
                 new String[]{String.valueOf(id)}, null);
 
         if (c != null && c.getCount() > 0) {
-            int commonId = c.getInt(c.getColumnIndex(EabProvider.EabCommonColumns._ID));
-            mContext.getContentResolver().delete(
-                    EabProvider.PRESENCE_URI, EabProvider.PresenceTupleColumns.EAB_COMMON_ID + "=?",
-                    new String[]{String.valueOf(commonId)});
+            while(c.moveToNext()) {
+                int commonId = c.getInt(c.getColumnIndex(EabProvider.EabCommonColumns._ID));
+                mContext.getContentResolver().delete(
+                        EabProvider.PRESENCE_URI,
+                        EabProvider.PresenceTupleColumns.EAB_COMMON_ID + "=?",
+                        new String[]{String.valueOf(commonId)});
+            }
         }
 
         if (c != null) {
@@ -508,10 +521,13 @@
                 new String[]{String.valueOf(contactId)}, null);
 
         if (c != null && c.getCount() > 0) {
-            int commonId = c.getInt(c.getColumnIndex(EabProvider.EabCommonColumns._ID));
-            mContext.getContentResolver().delete(
-                    EabProvider.OPTIONS_URI, EabProvider.OptionsColumns.EAB_COMMON_ID + "=?",
-                    new String[]{String.valueOf(commonId)});
+            while(c.moveToNext()) {
+                int commonId = c.getInt(c.getColumnIndex(EabProvider.EabCommonColumns._ID));
+                mContext.getContentResolver().delete(
+                        EabProvider.OPTIONS_URI,
+                        EabProvider.OptionsColumns.EAB_COMMON_ID + "=?",
+                        new String[]{String.valueOf(commonId)});
+            }
         }
 
         if (c != null) {
diff --git a/src/java/com/android/ims/rcs/uce/eab/EabProvider.java b/src/java/com/android/ims/rcs/uce/eab/EabProvider.java
index 3d509ee..b11004e 100644
--- a/src/java/com/android/ims/rcs/uce/eab/EabProvider.java
+++ b/src/java/com/android/ims/rcs/uce/eab/EabProvider.java
@@ -16,6 +16,11 @@
 
 package com.android.ims.rcs.uce.eab;
 
+import static android.content.ContentResolver.NOTIFY_DELETE;
+import static android.content.ContentResolver.NOTIFY_INSERT;
+import static android.content.ContentResolver.NOTIFY_SYNC_TO_NETWORK;
+import static android.content.ContentResolver.NOTIFY_UPDATE;
+
 import android.content.ContentProvider;
 import android.content.ContentValues;
 import android.content.Context;
@@ -25,6 +30,7 @@
 import android.database.sqlite.SQLiteOpenHelper;
 import android.database.sqlite.SQLiteQueryBuilder;
 import android.net.Uri;
+import android.os.UserHandle;
 import android.provider.BaseColumns;
 import android.text.TextUtils;
 import android.util.Log;
@@ -77,7 +83,7 @@
     public static final String AUTHORITY = "eab";
 
     private static final String TAG = "EabProvider";
-    private static final int DATABASE_VERSION = 1;
+    private static final int DATABASE_VERSION = 2;
 
     private static final String EAB_CONTACT_TABLE_NAME = "eab_contact";
     private static final String EAB_COMMON_TABLE_NAME = "eab_common";
@@ -137,6 +143,15 @@
 
         /**
          * The ID of contact that store in contact provider. It refer to the
+         * {@link android.provider.ContactsContract.Data#CONTACT_ID}. If the phone number not in
+         * contact provider, the value should be null.
+         *
+         * <P>Type: INTEGER</P>
+         */
+        public static final String CONTACT_ID = "contact_id";
+
+        /**
+         * The ID of contact that store in contact provider. It refer to the
          * {@link android.provider.ContactsContract.Data#RAW_CONTACT_ID}. If the phone number not in
          * contact provider, the value should be null.
          *
@@ -328,6 +343,7 @@
                 + " ("
                 + ContactColumns._ID + " INTEGER PRIMARY KEY, "
                 + ContactColumns.PHONE_NUMBER + " Text DEFAULT NULL, "
+                + ContactColumns.CONTACT_ID + " INTEGER DEFAULT -1, "
                 + ContactColumns.RAW_CONTACT_ID + " INTEGER DEFAULT -1, "
                 + ContactColumns.DATA_ID + " INTEGER DEFAULT -1, "
                 + "UNIQUE (" + TextUtils.join(", ", CONTACT_UNIQUE_FIELDS) + ")"
@@ -389,6 +405,12 @@
         @Override
         public void onUpgrade(SQLiteDatabase sqLiteDatabase, int oldVersion, int newVersion) {
             Log.d(TAG, "DB upgrade from " + oldVersion + " to " + newVersion);
+
+            if (oldVersion < 2) {
+                sqLiteDatabase.execSQL("ALTER TABLE " + EAB_CONTACT_TABLE_NAME + " ADD COLUMN "
+                        + ContactColumns.CONTACT_ID + " INTEGER DEFAULT -1;");
+                oldVersion = 2;
+            }
         }
     }
 
@@ -479,7 +501,7 @@
                 Log.d(TAG, "Query failed. Not support URL.");
                 return null;
         }
-        return qb.query(db, projection, selection, selectionArgs, null, null, null);
+        return qb.query(db, projection, selection, selectionArgs, null, null, sortOrder);
     }
 
     @Override
@@ -506,9 +528,13 @@
             result = db.insertWithOnConflict(tableName, null, contentValues,
                     SQLiteDatabase.CONFLICT_REPLACE);
             Log.d(TAG, "Insert uri: " + match + " ID: " + result);
+            if (result > 0) {
+                getContext().getContentResolver().notifyChange(uri, null, NOTIFY_INSERT);
+            }
         } else {
             Log.d(TAG, "Insert. Not support URI.");
         }
+
         return Uri.withAppendedPath(uri, String.valueOf(result));
     }
 
@@ -552,6 +578,9 @@
         } finally {
             db.endTransaction();
         }
+        if (result > 0) {
+            getContext().getContentResolver().notifyChange(uri, null, NOTIFY_INSERT);
+        }
         Log.d(TAG, "bulkInsert uri: " + match + " count: " + result);
         return result;
     }
@@ -578,6 +607,9 @@
         }
         if (!TextUtils.isEmpty(tableName)) {
             result = db.delete(tableName, selection, selectionArgs);
+            if (result > 0) {
+                getContext().getContentResolver().notifyChange(uri, null, NOTIFY_DELETE);
+            }
             Log.d(TAG, "Delete uri: " + match + " result: " + result);
         } else {
             Log.d(TAG, "Delete. Not support URI.");
@@ -609,6 +641,9 @@
         if (!TextUtils.isEmpty(tableName)) {
             result = db.updateWithOnConflict(tableName, contentValues,
                     selection, selectionArgs, SQLiteDatabase.CONFLICT_REPLACE);
+            if (result > 0) {
+                getContext().getContentResolver().notifyChange(uri, null, NOTIFY_UPDATE);
+            }
             Log.d(TAG, "Update uri: " + match + " result: " + result);
         } else {
             Log.d(TAG, "Update. Not support URI.");
@@ -623,13 +658,11 @@
 
     @VisibleForTesting
     public SQLiteDatabase getWritableDatabase() {
-        Log.d(TAG, "getWritableDatabase");
         return mOpenHelper.getWritableDatabase();
     }
 
     @VisibleForTesting
     public SQLiteDatabase getReadableDatabase() {
-        Log.d(TAG, "getReadableDatabase");
         return mOpenHelper.getReadableDatabase();
     }
 }
diff --git a/tests/AndroidManifest.xml b/tests/AndroidManifest.xml
index 706035a..88831aa 100644
--- a/tests/AndroidManifest.xml
+++ b/tests/AndroidManifest.xml
@@ -18,6 +18,11 @@
 <manifest xmlns:android="http://schemas.android.com/apk/res/android"
     package="com.android.ims.tests">
 
+    <!--  For EabBulkCapabilityUpdaterTest, EabBulkCapabilityUpdater will register content
+     observer to contact provider but currently there is no better way to mock contact provider
+     (registerContentObserver() is final), so require the read_contacts permission to test APK.-->
+    <uses-permission android:name="android.permission.READ_CONTACTS"/>
+
     <application android:label="@string/app_name">
         <uses-library android:name="android.test.runner" />
     </application>
diff --git a/tests/src/com/android/ims/ContextFixture.java b/tests/src/com/android/ims/ContextFixture.java
index c11a71a..5cbef29 100644
--- a/tests/src/com/android/ims/ContextFixture.java
+++ b/tests/src/com/android/ims/ContextFixture.java
@@ -37,6 +37,7 @@
 import android.telephony.CarrierConfigManager;
 import android.telephony.SubscriptionManager;
 import android.telephony.TelephonyManager;
+import android.telephony.ims.ImsManager;
 import android.test.mock.MockContentResolver;
 import android.test.mock.MockContext;
 
@@ -50,9 +51,11 @@
     private final Context mContext = spy(new FakeContext());
 
     private final TelephonyManager mTelephonyManager = mock(TelephonyManager.class);
+    private final ConnectivityManager mConnectivityManager = mock(ConnectivityManager.class);
     private final CarrierConfigManager mCarrierConfigManager = mock(CarrierConfigManager.class);
     private final PackageManager mPackageManager = mock(PackageManager.class);
     private final SubscriptionManager mSubscriptionManager = mock(SubscriptionManager.class);
+    private final ImsManager mImsManager = mock(ImsManager.class);
     private final Resources mResources = mock(Resources.class);
 
     private final PersistableBundle mBundle = new PersistableBundle();
@@ -91,8 +94,12 @@
                     return mTelephonyManager;
                 case Context.CARRIER_CONFIG_SERVICE:
                     return mCarrierConfigManager;
+                case Context.CONNECTIVITY_SERVICE:
+                    return mConnectivityManager;
                 case Context.TELEPHONY_SUBSCRIPTION_SERVICE:
                     return mSubscriptionManager;
+                case Context.TELEPHONY_IMS_SERVICE:
+                    return mImsManager;
                 default:
                     return null;
             }
@@ -108,6 +115,10 @@
                 return Context.CONNECTIVITY_SERVICE;
             } else if (serviceClass == TelephonyManager.class) {
                 return Context.TELEPHONY_SERVICE;
+            } else if (serviceClass == ImsManager.class) {
+                return Context.TELEPHONY_IMS_SERVICE;
+            } else if (serviceClass == CarrierConfigManager.class) {
+                return Context.CARRIER_CONFIG_SERVICE;
             }
             return super.getSystemServiceName(serviceClass);
         }
diff --git a/tests/src/com/android/ims/rcs/uce/eab/EabBulkCapabilityUpdaterTest.java b/tests/src/com/android/ims/rcs/uce/eab/EabBulkCapabilityUpdaterTest.java
new file mode 100644
index 0000000..7a52a40
--- /dev/null
+++ b/tests/src/com/android/ims/rcs/uce/eab/EabBulkCapabilityUpdaterTest.java
@@ -0,0 +1,177 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.ims.rcs.uce.eab;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+
+import android.content.SharedPreferences;
+import android.net.Uri;
+import android.os.Looper;
+import android.os.PersistableBundle;
+import android.telephony.CarrierConfigManager;
+import android.telephony.ims.ImsException;
+import android.telephony.ims.ImsManager;
+import android.telephony.ims.ImsRcsManager;
+import android.telephony.ims.RcsUceAdapter;
+import android.telephony.ims.aidl.IRcsUceControllerCallback;
+
+import com.android.ims.ImsTestBase;
+import com.android.ims.rcs.uce.UceController;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mock;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class EabBulkCapabilityUpdaterTest extends ImsTestBase {
+
+    private final int mSubId = 1;
+    @Mock
+    private UceController.UceControllerCallback mMockUceControllerCallback;
+    @Mock
+    private EabControllerImpl mMockEabControllerImpl;
+    @Mock
+    private ImsRcsManager mImsRcsManager;
+    @Mock
+    private RcsUceAdapter mRcsUceAdapter;
+    @Mock
+    private SharedPreferences mSharedPreferences;
+    @Mock
+    private SharedPreferences.Editor mSharedPreferencesEditor;
+    @Mock
+    private EabContactSyncController mEabContactSyncController;
+
+    @Before
+    public void setUp() throws Exception {
+        super.setUp();
+
+        doReturn(mSharedPreferences).when(mContext).getSharedPreferences(anyString(), anyInt());
+        doReturn(0L).when(mSharedPreferences).getLong(anyString(), anyInt());
+        doReturn(mSharedPreferencesEditor).when(mSharedPreferences).edit();
+        doReturn(mSharedPreferencesEditor).when(mSharedPreferencesEditor).putLong(anyString(),
+                anyLong());
+    }
+
+    @After
+    public void tearDown() throws Exception {
+        super.tearDown();
+    }
+
+    @Test
+    public void testRefreshCapabilities() throws Exception {
+        // mock user settings
+        mockUceUserSettings(true);
+        mockBulkCapabilityCarrierConfig(true);
+        // mock expired contact list
+        List<Uri> expiredContactList = new ArrayList<>();
+        expiredContactList.add(Uri.parse("test"));
+        doReturn(expiredContactList)
+                .when(mEabContactSyncController)
+                .syncContactToEabProvider(any());
+
+        EabBulkCapabilityUpdater eabBulkCapabilityUpdater = new EabBulkCapabilityUpdater(
+                mContext,
+                mSubId,
+                mMockEabControllerImpl,
+                mEabContactSyncController,
+                mMockUceControllerCallback,
+                Looper.getMainLooper());
+
+
+        verify(mMockUceControllerCallback).refreshCapabilities(
+                anyList(),
+                any(IRcsUceControllerCallback.class));
+    }
+
+    @Test
+    public void testUceSettingsDisabled() throws Exception {
+        // mock user settings
+        mockUceUserSettings(false);
+        mockBulkCapabilityCarrierConfig(true);
+        // mock expired contact list
+        List<Uri> expiredContactList = new ArrayList<>();
+        expiredContactList.add(Uri.parse("test"));
+        doReturn(expiredContactList)
+                .when(mEabContactSyncController)
+                .syncContactToEabProvider(any());
+
+        EabBulkCapabilityUpdater eabBulkCapabilityUpdater = new EabBulkCapabilityUpdater(
+                mContext,
+                mSubId,
+                mMockEabControllerImpl,
+                mEabContactSyncController,
+                mMockUceControllerCallback,
+                Looper.getMainLooper());
+
+        verify(mMockUceControllerCallback, never()).refreshCapabilities(
+                any(),
+                any(IRcsUceControllerCallback.class));
+    }
+
+    @Test
+    public void testCarrierConfigDisabled() throws Exception {
+        // mock user settings
+        mockUceUserSettings(true);
+        mockBulkCapabilityCarrierConfig(false);
+        // mock expired contact list
+        List<Uri> expiredContactList = new ArrayList<>();
+        expiredContactList.add(Uri.parse("test"));
+        doReturn(expiredContactList)
+                .when(mEabContactSyncController)
+                .syncContactToEabProvider(any());
+
+        EabBulkCapabilityUpdater eabBulkCapabilityUpdater = new EabBulkCapabilityUpdater(
+                mContext,
+                mSubId,
+                mMockEabControllerImpl,
+                mEabContactSyncController,
+                mMockUceControllerCallback,
+                Looper.getMainLooper());
+
+        verify(mMockUceControllerCallback, never()).refreshCapabilities(
+                anyList(),
+                any(IRcsUceControllerCallback.class));
+    }
+
+    private void mockBulkCapabilityCarrierConfig(boolean isEnabled) {
+        PersistableBundle persistableBundle = new PersistableBundle();
+        persistableBundle.putBoolean(
+                CarrierConfigManager.Ims.KEY_RCS_BULK_CAPABILITY_EXCHANGE_BOOL, isEnabled);
+        CarrierConfigManager carrierConfigManager =
+                mContext.getSystemService(CarrierConfigManager.class);
+        doReturn(persistableBundle).when(carrierConfigManager).getConfigForSubId(anyInt());
+    }
+
+    private void mockUceUserSettings(boolean isEnabled) throws ImsException {
+        // mock uce user settings
+        ImsManager imsManager = mContext.getSystemService(ImsManager.class);
+        doReturn(mImsRcsManager).when(imsManager).getImsRcsManager(eq(mSubId));
+        doReturn(mRcsUceAdapter).when(mImsRcsManager).getUceAdapter();
+        doReturn(isEnabled).when(mRcsUceAdapter).isUceSettingEnabled();
+    }
+}
diff --git a/tests/src/com/android/ims/rcs/uce/eab/EabContactSyncControllerTest.java b/tests/src/com/android/ims/rcs/uce/eab/EabContactSyncControllerTest.java
new file mode 100644
index 0000000..b500629
--- /dev/null
+++ b/tests/src/com/android/ims/rcs/uce/eab/EabContactSyncControllerTest.java
@@ -0,0 +1,275 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.ims.rcs.uce.eab;
+
+import static android.provider.ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE;
+
+import static org.junit.Assert.assertEquals;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doReturn;
+
+import android.content.ContentProvider;
+import android.content.ContentValues;
+import android.content.SharedPreferences;
+import android.content.pm.ProviderInfo;
+import android.database.Cursor;
+import android.database.MatrixCursor;
+import android.net.Uri;
+import android.provider.ContactsContract;
+import android.test.mock.MockContentResolver;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.test.rule.provider.ProviderTestRule;
+
+import com.android.ims.ImsTestBase;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.mockito.Mock;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+
+public class EabContactSyncControllerTest extends ImsTestBase {
+    private static final String TAG = "EabContactDataSyncServiceTest";
+
+    FakeContactProvider mFakeContactProvider = new FakeContactProvider();
+
+    @Rule
+    public ProviderTestRule mProviderTestRule = new ProviderTestRule.Builder(
+            EabProvider.class, EabProvider.AUTHORITY).build();
+
+    @Mock private SharedPreferences mSharedPreferences;
+    @Mock private SharedPreferences.Editor mSharedPreferencesEditor;
+
+    @Before
+    public void setUp() throws Exception {
+        super.setUp();
+        MockContentResolver mockContentResolver =
+                (MockContentResolver) mProviderTestRule.getResolver();
+        ProviderInfo providerInfo = new ProviderInfo();
+        providerInfo.authority = ContactsContract.AUTHORITY;
+        mFakeContactProvider.attachInfo(mContext, providerInfo);
+        mockContentResolver.addProvider(providerInfo.authority, mFakeContactProvider);
+        doReturn("com.android.phone.tests").when(mContext).getPackageName();
+
+        doReturn(mProviderTestRule.getResolver()).when(mContext).getContentResolver();
+
+        doReturn(mSharedPreferences).when(mContext).getSharedPreferences(anyString(), anyInt());
+        doReturn(0L).when(mSharedPreferences).getLong(anyString(), anyInt());
+        doReturn(mSharedPreferencesEditor).when(mSharedPreferences).edit();
+        doReturn(mSharedPreferencesEditor).when(mSharedPreferencesEditor).putLong(anyString(),
+                anyLong());
+    }
+
+    @After
+    public void tearDown() throws Exception {
+        super.tearDown();
+        mFakeContactProvider.clearData();
+        mContext.getContentResolver().delete(EabProvider.CONTACT_URI, null, null);
+    }
+
+    @Test
+    public void testContactDeletedCase() {
+        insertContactToEabProvider(1, 2, 3, "123456");
+        insertDeletedContactToContactProvider(1, 1);
+
+        new EabContactSyncController().syncContactToEabProvider(mContext);
+
+        Cursor result = mProviderTestRule.getResolver().query(
+                EabProvider.CONTACT_URI,
+                null,
+                null,
+                null);
+        assertEquals(0, result.getCount());
+    }
+
+    @Test
+    public void testMultipleContactsDeletedCase() {
+        // Insert 3 contacts in EabProvider
+        insertContactToEabProvider(1, 1, 1, "123456");
+        insertContactToEabProvider(2, 2, 2, "1234567");
+        insertContactToEabProvider(3, 3, 3, "12345678");
+        // Insert 2 deleted contacts
+        insertDeletedContactToContactProvider(1, 1);
+        insertDeletedContactToContactProvider(2, 1);
+
+        new EabContactSyncController().syncContactToEabProvider(mContext);
+
+        // Make sure only 1 contact in Eab DB
+        Cursor result = mProviderTestRule.getResolver().query(
+                EabProvider.CONTACT_URI,
+                null,
+                null,
+                null);
+        assertEquals(1, result.getCount());
+    }
+
+    @Test
+    public void testPhoneNumberDeletedCase() {
+        insertContactToEabProvider(1, 1, 2, "123456");
+        insertContactToEabProvider(1, 1, 3, "1234567");
+        insertContactToEabProvider(1, 1, 4, "12345678");
+        // Delete phone number 12345678
+        insertContactToContactProvider(1, 1, 2, "123456");
+        insertContactToContactProvider(1, 1, 3, "1234567");
+
+        new EabContactSyncController().syncContactToEabProvider(mContext);
+
+        Cursor result = mProviderTestRule.getResolver().query(
+                EabProvider.CONTACT_URI,
+                null,
+                null,
+                null);
+        assertEquals(2, result.getCount());
+    }
+
+    @Test
+    public void testPhoneNumberUpdatedCase() {
+        insertContactToEabProvider(1, 1, 2, "123456");
+        insertContactToEabProvider(1, 1, 3, "1234567");
+        insertContactToEabProvider(1, 1, 4, "12345678");
+        // Update phone number to 1,2,3
+        insertContactToContactProvider(1, 1, 2, "1");
+        insertContactToContactProvider(1, 1, 3, "2");
+        insertContactToContactProvider(1, 1, 4, "3");
+
+        new EabContactSyncController().syncContactToEabProvider(mContext);
+
+        Cursor result = mProviderTestRule.getResolver().query(
+                EabProvider.CONTACT_URI,
+                null,
+                null,
+                null,
+                EabProvider.ContactColumns.DATA_ID);
+        result.moveToFirst();
+        assertEquals(3, result.getCount());
+        assertEquals("1",
+                result.getString(result.getColumnIndex(EabProvider.ContactColumns.PHONE_NUMBER)));
+        result.moveToNext();
+        assertEquals("2",
+                result.getString(result.getColumnIndex(EabProvider.ContactColumns.PHONE_NUMBER)));
+        result.moveToNext();
+        assertEquals("3",
+                result.getString(result.getColumnIndex(EabProvider.ContactColumns.PHONE_NUMBER)));
+    }
+
+    private void insertDeletedContactToContactProvider(int contactId, int timestamp) {
+        ContentValues values = new ContentValues();
+        values.put(ContactsContract.DeletedContacts.CONTACT_ID, contactId);
+        values.put(ContactsContract.DeletedContacts.CONTACT_DELETED_TIMESTAMP, timestamp);
+        mContext.getContentResolver().insert(
+                ContactsContract.DeletedContacts.CONTENT_URI, values);
+    }
+
+    private void insertContactToContactProvider(
+            int contactId, int rawContactId, int dataId, String number) {
+        ContentValues values = new ContentValues();
+        values.put(ContactsContract.Data._ID, dataId);
+        values.put(EabProvider.ContactColumns.CONTACT_ID, contactId);
+        values.put(ContactsContract.CommonDataKinds.Phone.RAW_CONTACT_ID, rawContactId);
+        values.put(ContactsContract.Data.MIMETYPE, CONTENT_ITEM_TYPE);
+        values.put(ContactsContract.CommonDataKinds.Phone.NUMBER, number);
+        values.put(ContactsContract.CommonDataKinds.Phone.CONTACT_LAST_UPDATED_TIMESTAMP, 1);
+
+        mContext.getContentResolver().insert(ContactsContract.Data.CONTENT_URI, values);
+    }
+
+    private void insertContactToEabProvider(int contactId,
+            int rawContactId, int dataId, String phoneNumber) {
+        ContentValues values = new ContentValues();
+        values.put(EabProvider.ContactColumns.CONTACT_ID, contactId);
+        values.put(EabProvider.ContactColumns.RAW_CONTACT_ID, rawContactId);
+        values.put(EabProvider.ContactColumns.DATA_ID, dataId);
+        values.put(EabProvider.ContactColumns.PHONE_NUMBER, phoneNumber);
+        mContext.getContentResolver().insert(EabProvider.CONTACT_URI, values);
+    }
+
+    /**
+     * Create a fake contact provider that store ContentValues in hashmap when invoke insert()
+     * and convert to cursor when invoke query()
+     */
+    public static class FakeContactProvider extends ContentProvider {
+        private final HashMap<Uri, List<ContentValues>> mFakeProviderData = new HashMap<>();
+
+        public FakeContactProvider() {
+        }
+
+        public void clearData() {
+            mFakeProviderData.clear();
+        }
+
+        @Override
+        public boolean onCreate() {
+            return true;
+        }
+
+        @Override
+        public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs,
+                String sortOrder) {
+            return convertContentValuesToCursor(mFakeProviderData.get(uri));
+        }
+
+        @Nullable
+        @Override
+        public String getType(@NonNull Uri uri) {
+            return null;
+        }
+
+        @Nullable
+        @Override
+        public Uri insert(@NonNull Uri uri, @Nullable ContentValues values) {
+            List<ContentValues> allDataList =
+                    mFakeProviderData.computeIfAbsent(uri, k -> new ArrayList<>());
+            allDataList.add(new ContentValues(values));
+            return null;
+        }
+
+        @Override
+        public int delete(Uri uri, String selection, String[] selectionArgs) {
+            return 0;
+        }
+
+        @Override
+        public int update(@NonNull Uri uri, @Nullable ContentValues values,
+                @Nullable String selection, @Nullable String[] selectionArgs) {
+            return 0;
+        }
+
+        private Cursor convertContentValuesToCursor(List<ContentValues> valuesList) {
+            if (valuesList != null) {
+                MatrixCursor result =
+                        new MatrixCursor(valuesList.get(0).keySet().toArray(new String[0]));
+                for (ContentValues contentValue : valuesList) {
+                    MatrixCursor.RowBuilder builder = result.newRow();
+                    for (String key : contentValue.keySet()) {
+                        builder.add(key, contentValue.get(key));
+                    }
+                }
+                return result;
+            } else {
+                return new MatrixCursor(new String[0]);
+            }
+        }
+    }
+}
diff --git a/tests/src/com/android/ims/rcs/uce/eab/EabControllerTest.java b/tests/src/com/android/ims/rcs/uce/eab/EabControllerTest.java
index ceae905..7e3783b 100644
--- a/tests/src/com/android/ims/rcs/uce/eab/EabControllerTest.java
+++ b/tests/src/com/android/ims/rcs/uce/eab/EabControllerTest.java
@@ -25,6 +25,7 @@
 
 import android.content.ContentValues;
 import android.net.Uri;
+import android.os.Looper;
 import android.telephony.ims.RcsContactPresenceTuple;
 import android.telephony.ims.RcsContactUceCapability;
 import android.test.mock.MockContentResolver;
@@ -69,7 +70,8 @@
         mockContentResolver.addProvider(EabProvider.AUTHORITY, mEabProviderTestable);
 
         insertContactInfoToDB();
-        mEabController = new EabControllerImpl(mContext, TEST_SUB_ID, null, null);
+        mEabController = new EabControllerImpl(
+                mContext, TEST_SUB_ID, null, Looper.getMainLooper());
     }
 
     @After