Trigger deltaUpdate in ContactsIndexerUserInstance

1) Create and initialize ContactsIndexerImpl in ContactsIndexerUserInstance
2) trigger the delta update in ContactsIndexerUserInstance to make it work
   end to end. Namely if contacts are changed on the device, we will see
   those changes reflected in AppSearch.
3) correctly set and persist last delta update and delete timestamps

This is splitted from ag/16569486. The first part is ag/16981486.

One Pager: go/appsearch-contacts-indexer-one-pager
Design doc: go/appsearch-contacts-indexer(Implementation Plan:
https://docs.google.com/document/d/1qfek20XQQYh02V2J6K0pXfrhnXYmVH-1NqwI8Xiqd10/edit#heading=h.8tlk1jy5d7ek)
Prototype: ag/16130927

Bug: 203605504
Test: AppSearchServicesTests
Also did manual test on device with this feature enabled:
1) added 5/100/1000 contacts
2) wait until indexing finishes while monitoring the cpu and memory
   usage. No major issue found
3) use dumper to verfiy the documents are available in AppSearch
adb pull /data/system_ce/0/appsearch
blaze-bin/third_party/icing/tools/dumper appsearch/icing/ > log

Change-Id: Ie8321bbe93dc03b277ea62f21b6fd85ded6450df
diff --git a/service/java/com/android/server/appsearch/AppSearchManagerService.java b/service/java/com/android/server/appsearch/AppSearchManagerService.java
index 42ce20c..0b25057 100644
--- a/service/java/com/android/server/appsearch/AppSearchManagerService.java
+++ b/service/java/com/android/server/appsearch/AppSearchManagerService.java
@@ -212,7 +212,7 @@
                 return;
             }
             // Only clear the package's data if AppSearch exists for this user.
-            if (AppSearchUserInstanceManager.getAppSearchDir(userHandle).exists()) {
+            if (AppSearchModule.getAppSearchDir(userHandle).exists()) {
                 Context userContext = mContext.createContextAsUser(userHandle, /*flags=*/ 0);
                 AppSearchUserInstance instance =
                         mAppSearchUserInstanceManager.getOrCreateUserInstance(
@@ -237,7 +237,7 @@
         mExecutorManager.getOrCreateUserExecutor(userHandle).execute(() -> {
             try {
                 // Only clear the package's data if AppSearch exists for this user.
-                if (AppSearchUserInstanceManager.getAppSearchDir(userHandle).exists()) {
+                if (AppSearchModule.getAppSearchDir(userHandle).exists()) {
                     Context userContext = mContext.createContextAsUser(userHandle, /*flags=*/ 0);
                     AppSearchUserInstance instance =
                             mAppSearchUserInstanceManager.getOrCreateUserInstance(
diff --git a/service/java/com/android/server/appsearch/AppSearchModule.java b/service/java/com/android/server/appsearch/AppSearchModule.java
index 1adf5fe..0f9c9ac 100644
--- a/service/java/com/android/server/appsearch/AppSearchModule.java
+++ b/service/java/com/android/server/appsearch/AppSearchModule.java
@@ -19,6 +19,8 @@
 import android.annotation.NonNull;
 import android.annotation.Nullable;
 import android.content.Context;
+import android.os.Environment;
+import android.os.UserHandle;
 import android.provider.DeviceConfig;
 import android.util.Log;
 
@@ -26,9 +28,24 @@
 import com.android.server.appsearch.contactsindexer.ContactsIndexerConfig;
 import com.android.server.appsearch.contactsindexer.ContactsIndexerManagerService;
 
+import java.io.File;
+
 public class AppSearchModule {
     private static final String TAG = "AppSearchModule";
 
+    /**
+     * Returns AppSearch directory in the credential encrypted system directory for the given user.
+     *
+     * <p>This folder should only be accessed after unlock.
+     */
+    public static File getAppSearchDir(@NonNull UserHandle userHandle) {
+        // Duplicates the implementation of Environment#getDataSystemCeDirectory
+        // TODO(b/191059409): Unhide Environment#getDataSystemCeDirectory and switch to it.
+        File systemCeDir = new File(Environment.getDataDirectory(), "system_ce");
+        File systemCeUserDir = new File(systemCeDir, String.valueOf(userHandle.getIdentifier()));
+        return new File(systemCeUserDir, "appsearch");
+    }
+
     public static final class Lifecycle extends SystemService {
         private AppSearchManagerService mAppSearchManagerService;
         @Nullable private ContactsIndexerManagerService mContactsIndexerManagerService;
@@ -50,6 +67,8 @@
                 return;
             }
 
+            // It is safe to check DeviceConfig here, since SettingsProvider, which DeviceConfig
+            // uses, starts before AppSearch.
             if (DeviceConfig.getBoolean(
                     DeviceConfig.NAMESPACE_APPSEARCH,
                     ContactsIndexerConfig.CONTACTS_INDEXER_ENABLED,
@@ -57,18 +76,19 @@
                 mContactsIndexerManagerService = new ContactsIndexerManagerService(getContext());
                 try {
                     mContactsIndexerManagerService.onStart();
-                } catch (Exception e) {
-                    Log.e(TAG, "Failed to start Contacts Indexer service", e);
+                } catch (Throwable t) {
+                    Log.e(TAG, "Failed to start ContactsIndexer service", t);
                     // Release the Contacts Indexer instance as it won't be started until the next
                     // system_server restart on a device reboot.
                     mContactsIndexerManagerService = null;
                 }
+            } else {
+                Log.i(TAG, "ContactsIndexer service is disabled.");
             }
         }
 
         @Override
         public void onBootPhase(int phase) {
-            super.onBootPhase(phase);
             mAppSearchManagerService.onBootPhase(phase);
         }
 
diff --git a/service/java/com/android/server/appsearch/AppSearchUserInstanceManager.java b/service/java/com/android/server/appsearch/AppSearchUserInstanceManager.java
index 2966057..ee1eca5 100644
--- a/service/java/com/android/server/appsearch/AppSearchUserInstanceManager.java
+++ b/service/java/com/android/server/appsearch/AppSearchUserInstanceManager.java
@@ -20,7 +20,6 @@
 import android.annotation.Nullable;
 import android.app.appsearch.exceptions.AppSearchException;
 import android.content.Context;
-import android.os.Environment;
 import android.os.SystemClock;
 import android.os.UserHandle;
 import android.util.ArrayMap;
@@ -75,19 +74,6 @@
     }
 
     /**
-     * Returns AppSearch directory in the credential encrypted system directory for the given user.
-     *
-     * <p>This folder should only be accessed after unlock.
-     */
-    public static File getAppSearchDir(@NonNull UserHandle userHandle) {
-        // Duplicates the implementation of Environment#getDataSystemCeDirectory
-        // TODO(b/191059409): Unhide Environment#getDataSystemCeDirectory and switch to it.
-        File systemCeDir = new File(Environment.getDataDirectory(), "system_ce");
-        File systemCeUserDir = new File(systemCeDir, String.valueOf(userHandle.getIdentifier()));
-        return new File(systemCeUserDir, "appsearch");
-    }
-
-    /**
      * Gets an instance of AppSearchUserInstance for the given user, or creates one if none exists.
      *
      * <p>If no AppSearchUserInstance exists for the unlocked user, Icing will be initialized and
@@ -191,7 +177,7 @@
         synchronized (mStorageInfoLocked) {
             UserStorageInfo userStorageInfo = mStorageInfoLocked.get(userHandle);
             if (userStorageInfo == null) {
-                userStorageInfo = new UserStorageInfo(getAppSearchDir(userHandle));
+                userStorageInfo = new UserStorageInfo(AppSearchModule.getAppSearchDir(userHandle));
                 mStorageInfoLocked.put(userHandle, userStorageInfo);
             }
             return userStorageInfo;
@@ -222,7 +208,7 @@
         // Initialize the classes that make up AppSearchUserInstance
         PlatformLogger logger = new PlatformLogger(userContext, config);
 
-        File appSearchDir = getAppSearchDir(userHandle);
+        File appSearchDir = AppSearchModule.getAppSearchDir(userHandle);
         File icingDir = new File(appSearchDir, "icing");
         Log.i(TAG, "Creating new AppSearch instance at: " + icingDir);
         VisibilityCheckerImpl visibilityCheckerImpl = new VisibilityCheckerImpl(userContext);
diff --git a/service/java/com/android/server/appsearch/contactsindexer/AppSearchHelper.java b/service/java/com/android/server/appsearch/contactsindexer/AppSearchHelper.java
index 1c81ba4..fd9280e 100644
--- a/service/java/com/android/server/appsearch/contactsindexer/AppSearchHelper.java
+++ b/service/java/com/android/server/appsearch/contactsindexer/AppSearchHelper.java
@@ -60,6 +60,7 @@
     public static final String NAMESPACE_NAME = "";
 
     private final Context mContext;
+    private final Executor mExecutor;
     private volatile AppSearchSession mAppSearchSession;
 
     /**
@@ -71,22 +72,22 @@
     @NonNull
     public static AppSearchHelper createAppSearchHelper(@NonNull Context context,
             @NonNull Executor executor) throws InterruptedException, ExecutionException {
-        AppSearchHelper appSearchHelper = new AppSearchHelper(Objects.requireNonNull(context));
-        appSearchHelper.initialize(Objects.requireNonNull(executor));
+        AppSearchHelper appSearchHelper = new AppSearchHelper(Objects.requireNonNull(context),
+                Objects.requireNonNull(executor));
+        appSearchHelper.initialize();
         return appSearchHelper;
     }
 
     @VisibleForTesting
-    AppSearchHelper(@NonNull Context context) {
+    AppSearchHelper(@NonNull Context context, @NonNull Executor executor) {
         mContext = Objects.requireNonNull(context);
+        mExecutor = Objects.requireNonNull(executor);
     }
 
     /** Initializes the {@link AppSearchHelper}. */
     @WorkerThread
-    private void initialize(@NonNull Executor executor)
+    private void initialize()
             throws InterruptedException, ExecutionException {
-        Objects.requireNonNull(executor);
-
         AppSearchManager appSearchManager = mContext.getSystemService(AppSearchManager.class);
         if (appSearchManager == null) {
             throw new AndroidRuntimeException(
@@ -94,12 +95,12 @@
         }
 
         try {
-            mAppSearchSession = createAppSearchSessionAsync(appSearchManager, executor).get();
+            mAppSearchSession = createAppSearchSessionAsync(appSearchManager).get();
             // Always force set the schema. We are at the 1st version, so it should be fine for
             // doing it.
             // For future schema changes, we could also force set it, and rely on a full update
             // to bring back wiped data.
-            setPersonSchemaAsync(mAppSearchSession, /*forceOverride=*/ true, executor).get();
+            setPersonSchemaAsync(mAppSearchSession, /*forceOverride=*/ true).get();
         } catch (InterruptedException | ExecutionException | RuntimeException e) {
             Log.e(TAG, "Failed to create or config a AppSearchSession during initialization.", e);
             mAppSearchSession = null;
@@ -114,15 +115,13 @@
      * created, which must be done before ContactsIndexer starts handling CP2 changes.
      */
     private CompletableFuture<AppSearchSession> createAppSearchSessionAsync(
-            @NonNull AppSearchManager appSearchManager,
-            @NonNull Executor executor) {
-        Objects.requireNonNull(executor);
+            @NonNull AppSearchManager appSearchManager) {
         Objects.requireNonNull(appSearchManager);
 
         CompletableFuture<AppSearchSession> future = new CompletableFuture<>();
         final AppSearchManager.SearchContext searchContext =
                 new AppSearchManager.SearchContext.Builder(DATABASE_NAME).build();
-        appSearchManager.createSearchSession(searchContext, executor, result -> {
+        appSearchManager.createSearchSession(searchContext, mExecutor, result -> {
             if (result.isSuccess()) {
                 future.complete(result.getResultValue());
             } else {
@@ -147,9 +146,8 @@
      */
     @NonNull
     private CompletableFuture<Void> setPersonSchemaAsync(@NonNull AppSearchSession session,
-            boolean forceOverride, @NonNull Executor executor) {
+            boolean forceOverride) {
         Objects.requireNonNull(session);
-        Objects.requireNonNull(executor);
 
         CompletableFuture<Void> future = new CompletableFuture<>();
         SetSchemaRequest.Builder schemaBuilder = new SetSchemaRequest.Builder()
@@ -157,7 +155,7 @@
                 .addRequiredPermissionsForSchemaTypeVisibility(Person.SCHEMA_TYPE,
                         Collections.singleton(SetSchemaRequest.READ_CONTACTS))
                 .setForceOverride(forceOverride);
-        session.setSchema(schemaBuilder.build(), executor, executor,
+        session.setSchema(schemaBuilder.build(), mExecutor, mExecutor,
                 result -> {
                     if (result.isSuccess()) {
                         future.complete(null);
@@ -186,17 +184,15 @@
      *                 be thrown.
      */
     @NonNull
-    public CompletableFuture<Void> indexContactsAsync(@NonNull Collection<Person> contacts,
-            @NonNull Executor executor) {
+    public CompletableFuture<Void> indexContactsAsync(@NonNull Collection<Person> contacts) {
         Objects.requireNonNull(contacts);
-        Objects.requireNonNull(executor);
 
         // Get the size before doing an async call.
         int size = contacts.size();
         CompletableFuture<Void> future = new CompletableFuture<>();
         PutDocumentsRequest request = new PutDocumentsRequest.Builder().addGenericDocuments(
                 contacts).build();
-        mAppSearchSession.put(request, executor, new BatchResultCallback<String, Void>() {
+        mAppSearchSession.put(request, mExecutor, new BatchResultCallback<String, Void>() {
             @Override
             public void onResult(AppSearchBatchResult<String, Void> result) {
                 if (result.isSuccess()) {
@@ -230,15 +226,13 @@
      *            be thrown.
      */
     @NonNull
-    public CompletableFuture<Void> removeContactsByIdAsync(@NonNull Collection<String> ids,
-            @NonNull Executor executor) {
+    public CompletableFuture<Void> removeContactsByIdAsync(@NonNull Collection<String> ids) {
         Objects.requireNonNull(ids);
-        Objects.requireNonNull(executor);
 
         CompletableFuture<Void> future = new CompletableFuture<>();
         RemoveByDocumentIdRequest request = new RemoveByDocumentIdRequest.Builder(
                 NAMESPACE_NAME).addIds(ids).build();
-        mAppSearchSession.remove(request, executor, new BatchResultCallback<String, Void>() {
+        mAppSearchSession.remove(request, mExecutor, new BatchResultCallback<String, Void>() {
             @Override
             public void onResult(AppSearchBatchResult<String, Void> result) {
                 if (result.isSuccess()) {
diff --git a/service/java/com/android/server/appsearch/contactsindexer/ContactDataHandler.java b/service/java/com/android/server/appsearch/contactsindexer/ContactDataHandler.java
index 19a5762..2fef22a 100644
--- a/service/java/com/android/server/appsearch/contactsindexer/ContactDataHandler.java
+++ b/service/java/com/android/server/appsearch/contactsindexer/ContactDataHandler.java
@@ -254,9 +254,8 @@
         @NonNull
         @Override
         protected String getTypeLabel(@NonNull Resources resources, int type,
-                @NonNull String label) {
+                @Nullable String label) {
             Objects.requireNonNull(resources);
-            Objects.requireNonNull(label);
             return Email.getTypeLabel(resources, type, label).toString();
         }
     }
@@ -289,9 +288,8 @@
         @NonNull
         @Override
         protected String getTypeLabel(@NonNull Resources resources, int type,
-                @NonNull String label) {
+                @Nullable String label) {
             Objects.requireNonNull(resources);
-            Objects.requireNonNull(label);
             return Phone.getTypeLabel(resources, type, label).toString();
         }
     }
@@ -329,9 +327,8 @@
         @NonNull
         @Override
         protected String getTypeLabel(@NonNull Resources resources, int type,
-                @NonNull String label) {
+                @Nullable String label) {
             Objects.requireNonNull(resources);
-            Objects.requireNonNull(label);
             return StructuredPostal.getTypeLabel(resources, type, label).toString();
         }
     }
diff --git a/service/java/com/android/server/appsearch/contactsindexer/ContactsIndexerImpl.java b/service/java/com/android/server/appsearch/contactsindexer/ContactsIndexerImpl.java
index 5d8a062..9fcb224 100644
--- a/service/java/com/android/server/appsearch/contactsindexer/ContactsIndexerImpl.java
+++ b/service/java/com/android/server/appsearch/contactsindexer/ContactsIndexerImpl.java
@@ -17,15 +17,14 @@
 package com.android.server.appsearch.contactsindexer;
 
 import android.annotation.NonNull;
+import android.annotation.Nullable;
 import android.content.Context;
-import android.content.res.Resources;
 import android.database.Cursor;
 import android.net.Uri;
 import android.provider.ContactsContract;
 import android.text.TextUtils;
 import android.util.ArraySet;
 import android.util.Log;
-import android.util.Pair;
 
 import com.android.internal.annotations.VisibleForTesting;
 import com.android.server.appsearch.contactsindexer.appsearchtypes.Person;
@@ -36,7 +35,6 @@
 import java.util.List;
 import java.util.Objects;
 import java.util.Set;
-import java.util.concurrent.Executor;
 
 /**
  * The class to sync the data from CP2 to AppSearch.
@@ -48,10 +46,10 @@
 public final class ContactsIndexerImpl {
     static final String TAG = "ContactsIndexerImpl";
 
-    static final int NUM_UPDATED_CONTACTS_PER_BATCH_FOR_APPSEARCH = 50;
-    static final int NUM_DELETED_CONTACTS_PER_BATCH_FOR_APPSEARCH = 500;
     // TODO(b/203605504) have and read those flags in/from AppSearchConfig.
     static final int NUM_CONTACTS_PER_BATCH_FOR_CP2 = 100;
+    static final int NUM_UPDATED_CONTACTS_PER_BATCH_FOR_APPSEARCH = 50;
+    static final int NUM_DELETED_CONTACTS_PER_BATCH_FOR_APPSEARCH = 500;
     // Common columns needed for all kinds of mime types
     static final String[] COMMON_NEEDED_COLUMNS = {
             ContactsContract.Data.CONTACT_ID,
@@ -84,45 +82,18 @@
     private final ContactDataHandler mContactDataHandler;
     private final String[] mProjection;
     private final AppSearchHelper mAppSearchHelper;
-    private final Executor mExecutorForAppSearch;
     private final ContactsBatcher mBatcher;
 
-    ContactsIndexerImpl(@NonNull Context context, @NonNull AppSearchHelper appSearchHelper,
-            @NonNull Executor executorForAppSearch) {
+    public ContactsIndexerImpl(@NonNull Context context, @NonNull AppSearchHelper appSearchHelper) {
         mContext = Objects.requireNonNull(context);
         mAppSearchHelper = Objects.requireNonNull(appSearchHelper);
-        mExecutorForAppSearch = Objects.requireNonNull(executorForAppSearch);
         mContactDataHandler = new ContactDataHandler(mContext.getResources());
 
         Set<String> neededColumns = new ArraySet<>(Arrays.asList(COMMON_NEEDED_COLUMNS));
         neededColumns.addAll(mContactDataHandler.getNeededColumns());
-
         mProjection = neededColumns.toArray(new String[0]);
         mBatcher = new ContactsBatcher(mAppSearchHelper,
-                NUM_UPDATED_CONTACTS_PER_BATCH_FOR_APPSEARCH,
-                mExecutorForAppSearch);
-    }
-
-    /**
-     * Syncs all changed and deleted contacts since the last snapshot into AppSearch Person corpus.
-     *
-     * @param lastUpdatedTimestamp timestamp (millis since epoch) for the contact last updated.
-     * @param lastDeletedTimestamp timestamp (millis since epoch) for the contact last deleted.
-     * @return (updatedLastUpdatedTimestamp, updatedLastDeleteTimestamp)
-     */
-    Pair<Long, Long> doDeltaUpdate(long lastUpdatedTimestamp, long lastDeletedTimestamp) {
-        Set<String> wantedContactIds = new ArraySet<>();
-        Set<String> unWantedContactIds = new ArraySet<>();
-
-        lastUpdatedTimestamp = ContactsProviderUtil.getUpdatedContactIds(mContext,
-                lastUpdatedTimestamp, wantedContactIds);
-        lastDeletedTimestamp = ContactsProviderUtil.getDeletedContactIds(mContext,
-                lastDeletedTimestamp, unWantedContactIds);
-
-        // Updates AppSearch based on those two lists.
-        updatePersonCorpus(mContext.getResources(), wantedContactIds, unWantedContactIds);
-
-        return new Pair<>(lastUpdatedTimestamp, lastDeletedTimestamp);
+                NUM_UPDATED_CONTACTS_PER_BATCH_FOR_APPSEARCH);
     }
 
     @VisibleForTesting
@@ -136,15 +107,19 @@
             int endIndex = Math.min(startIndex + NUM_DELETED_CONTACTS_PER_BATCH_FOR_APPSEARCH,
                     unWantedSize);
             Collection<String> currentContactIds = unWantedIdList.subList(startIndex, endIndex);
-            mAppSearchHelper.removeContactsByIdAsync(currentContactIds, mExecutorForAppSearch);
+            mAppSearchHelper.removeContactsByIdAsync(currentContactIds);
             startIndex = endIndex;
         }
     }
 
-    /** Updates Person corpus in AppSearch. */
-    private void updatePersonCorpus(@NonNull Resources resources,
-            @NonNull Set<String> wantedContactIds, @NonNull Set<String> unWantedContactIds) {
-        Objects.requireNonNull(resources);
+    /**
+     * Updates Person corpus in AppSearch.
+     *
+     * @param wantedContactIds   ids for contacts to be updated.
+     * @param unWantedContactIds ids for contacts to be deleted.
+     */
+    void updatePersonCorpus(@NonNull Set<String> wantedContactIds,
+            @NonNull Set<String> unWantedContactIds) {
         Objects.requireNonNull(wantedContactIds);
         Objects.requireNonNull(unWantedContactIds);
 
@@ -168,7 +143,6 @@
             String selection = ContactsContract.Data.CONTACT_ID + " IN (" + TextUtils.join(
                     /*delimiter=*/ ",", currentContactIds) + ")";
             startIndex = endIndex;
-
             Cursor cursor = null;
             try {
                 // For our iteration work, we must sort the result by contact_id first.
@@ -198,21 +172,25 @@
     }
 
     /**
-     * Reads through cursor, converts the contacts to AppSearch documents, and indexes the documents
-     * into AppSearch.
+     * Reads through cursor, converts the contacts to AppSearch documents, and indexes the
+     * documents into AppSearch.
+     *
+     * @param cursor pointing to the contacts read from CP2.
      */
     private void indexContactsFromCursorToAppSearch(@NonNull Cursor cursor) {
         Objects.requireNonNull(cursor);
 
         int contactIdIndex = cursor.getColumnIndex(ContactsContract.Data.CONTACT_ID);
         int lookupKeyIndex = cursor.getColumnIndex(ContactsContract.Data.LOOKUP_KEY);
-        int thumbnailUriIndex = cursor.getColumnIndex(ContactsContract.Data.PHOTO_THUMBNAIL_URI);
-        int displayNameIndex = cursor.getColumnIndex(ContactsContract.Data.DISPLAY_NAME_PRIMARY);
+        int thumbnailUriIndex = cursor.getColumnIndex(
+                ContactsContract.Data.PHOTO_THUMBNAIL_URI);
+        int displayNameIndex = cursor.getColumnIndex(
+                ContactsContract.Data.DISPLAY_NAME_PRIMARY);
         int phoneticNameIndex = cursor.getColumnIndex(ContactsContract.Data.PHONETIC_NAME);
         int starredIndex = cursor.getColumnIndex(ContactsContract.Data.STARRED);
         long currentContactId = -1;
         Person.Builder personBuilder = null;
-        PersonBuilderHelper personBuilderHlper = null;
+        PersonBuilderHelper personBuilderHelper = null;
         try {
             while (cursor != null && cursor.moveToNext()) {
                 long contactId = cursor.getLong(contactIdIndex);
@@ -222,22 +200,28 @@
                     if (currentContactId != -1) {
                         // It is the first row for a new contact_id. We can wrap up the
                         // ContactData for the previous contact_id.
-                        mBatcher.add(personBuilderHlper.buildPerson());
+                        mBatcher.add(personBuilderHelper.buildPerson());
                     }
                     // New set of builder and builderHelper for the new contact.
                     currentContactId = contactId;
-                    personBuilder = new Person.Builder(
-                            AppSearchHelper.NAMESPACE_NAME,
-                            String.valueOf(contactId),
-                            cursor.getString(displayNameIndex));
-                    // TODO(b/203605504) add a helper to make those lines below cleaner.
-                    //  We could also include those in ContactDataHandler.
-                    String imageUri = cursor.getString(thumbnailUriIndex);
-                    String phoneticName = cursor.getString(phoneticNameIndex);
-                    String lookupKey = cursor.getString(lookupKeyIndex);
-                    boolean starred = cursor.getInt(starredIndex) != 0;
-                    Uri lookupUri = ContactsContract.Contacts.getLookupUri(currentContactId,
-                            lookupKey);
+                    String displayName = getStringFromCursor(cursor, displayNameIndex);
+                    if (displayName == null) {
+                        // For now, we don't abandon the data if displayName is missing. In the
+                        // schema the name is required for building a person. It might look bad
+                        // if there are contacts in CP2, but not in AppSearch, even though the
+                        // name is missing.
+                        displayName = "";
+                    }
+                    personBuilder = new Person.Builder(AppSearchHelper.NAMESPACE_NAME,
+                            String.valueOf(contactId), displayName);
+                    String imageUri = getStringFromCursor(cursor, thumbnailUriIndex);
+                    String phoneticName = getStringFromCursor(cursor, phoneticNameIndex);
+                    String lookupKey = getStringFromCursor(cursor, lookupKeyIndex);
+                    boolean starred = starredIndex != -1 ?
+                            cursor.getInt(starredIndex) != 0 : false;
+                    Uri lookupUri = lookupKey != null ?
+                            ContactsContract.Contacts.getLookupUri(currentContactId, lookupKey)
+                            : null;
                     personBuilder.setIsImportant(starred);
                     if (lookupUri != null) {
                         personBuilder.setExternalUri(lookupUri);
@@ -249,10 +233,10 @@
                         personBuilder.addAdditionalName(phoneticName);
                     }
 
-                    personBuilderHlper = new PersonBuilderHelper(personBuilder);
+                    personBuilderHelper = new PersonBuilderHelper(personBuilder);
                 }
-                if (personBuilderHlper != null) {
-                    mContactDataHandler.convertCursorToPerson(cursor, personBuilderHlper);
+                if (personBuilderHelper != null) {
+                    mContactDataHandler.convertCursorToPerson(cursor, personBuilderHelper);
                 }
             }
         } catch (Throwable t) {
@@ -263,8 +247,8 @@
         if (cursor.isAfterLast() && currentContactId != -1) {
             // The ContactData for the last contact has not been handled yet. So we need to
             // build and index it.
-            if (personBuilderHlper != null) {
-                mBatcher.add(personBuilderHlper.buildPerson());
+            if (personBuilderHelper != null) {
+                mBatcher.add(personBuilderHelper.buildPerson());
             }
         }
 
@@ -273,18 +257,29 @@
     }
 
     /**
+     * Helper method to read the value from a {@link Cursor} for {@code index}.
+     *
+     * @return A string value, or {@code null} if the value is missing, or {@code index} is -1.
+     */
+    @Nullable
+    private static String getStringFromCursor(@NonNull Cursor cursor, int index) {
+        Objects.requireNonNull(cursor);
+        if (index != -1) {
+            return cursor.getString(index);
+        }
+        return null;
+    }
+
+    /**
      * Class for helping batching the {@link Person} to be indexed.
      */
     static class ContactsBatcher {
         private final List<Person> mBatchedContacts;
         private final int mBatchSize;
         private final AppSearchHelper mAppSearchHelper;
-        private final Executor mExecutorForAppSearch;
 
-        ContactsBatcher(@NonNull AppSearchHelper appSearchHelper, int batchSize,
-                @NonNull Executor appSearchExecutor) {
+        ContactsBatcher(@NonNull AppSearchHelper appSearchHelper, int batchSize) {
             mAppSearchHelper = Objects.requireNonNull(appSearchHelper);
-            mExecutorForAppSearch = Objects.requireNonNull(appSearchExecutor);
             mBatchSize = batchSize;
             mBatchedContacts = new ArrayList<>(mBatchSize);
         }
@@ -313,8 +308,8 @@
                 return;
             }
 
-            mAppSearchHelper.indexContactsAsync(mBatchedContacts, mExecutorForAppSearch);
+            mAppSearchHelper.indexContactsAsync(mBatchedContacts);
             mBatchedContacts.clear();
         }
-    };
+    }
 }
diff --git a/service/java/com/android/server/appsearch/contactsindexer/ContactsIndexerMaintenanceService.java b/service/java/com/android/server/appsearch/contactsindexer/ContactsIndexerMaintenanceService.java
index 5ff0a38..7bc1b29 100644
--- a/service/java/com/android/server/appsearch/contactsindexer/ContactsIndexerMaintenanceService.java
+++ b/service/java/com/android/server/appsearch/contactsindexer/ContactsIndexerMaintenanceService.java
@@ -25,6 +25,7 @@
 import android.content.Context;
 import android.os.Bundle;
 import android.os.CancellationSignal;
+import android.util.Log;
 
 import com.android.server.LocalManagerRegistry;
 
@@ -34,6 +35,7 @@
 import java.util.concurrent.TimeUnit;
 
 public class ContactsIndexerMaintenanceService extends JobService {
+    private static final String TAG = "ContactsIndexerMaintenanceService";
 
     /** This job ID must be unique within the system server. */
     private static final int ONE_OFF_FULL_UPDATE_JOB_ID = 0x1B7DED30; // 461237552
@@ -50,6 +52,8 @@
      * Schedules a one-off full update job for the given device-user.
      */
     static void scheduleOneOffFullUpdateJob(Context context, @UserIdInt int userId) {
+        Log.v(TAG, "One off full updated job scheduled for " + userId);
+
         JobScheduler jobScheduler = context.getSystemService(JobScheduler.class);
         ComponentName component =
                 new ComponentName(context, ContactsIndexerMaintenanceService.class);
@@ -70,6 +74,8 @@
         if (userId == -1) {
             return false;
         }
+
+        Log.v(TAG, "One off full updated job started for " + userId);
         mSignal = new CancellationSignal();
         EXECUTOR.execute(() -> {
             ContactsIndexerManagerService.LocalService service =
diff --git a/service/java/com/android/server/appsearch/contactsindexer/ContactsIndexerManagerService.java b/service/java/com/android/server/appsearch/contactsindexer/ContactsIndexerManagerService.java
index 71286f8..84b81da 100644
--- a/service/java/com/android/server/appsearch/contactsindexer/ContactsIndexerManagerService.java
+++ b/service/java/com/android/server/appsearch/contactsindexer/ContactsIndexerManagerService.java
@@ -21,17 +21,20 @@
 import android.content.Context;
 import android.os.CancellationSignal;
 import android.os.UserHandle;
+import android.util.Log;
 import android.util.SparseArray;
 
 import com.android.server.LocalManagerRegistry;
 import com.android.server.SystemService;
+import com.android.server.appsearch.AppSearchModule;
 
+import java.io.File;
 import java.util.Objects;
 
 /**
  * Manages the per device-user ContactsIndexer instance to index CP2 contacts into AppSearch.
  *
- * <p>This class is thread safe.
+ * <p>This class is thread-safe.
  *
  * @hide
  */
@@ -55,7 +58,7 @@
     }
 
     @Override
-    public void onUserStarting(@NonNull TargetUser user) {
+    public void onUserUnlocking(@NonNull TargetUser user) {
         Objects.requireNonNull(user);
         UserHandle userHandle = user.getUserHandle();
         int userId = userHandle.getIdentifier();
@@ -63,8 +66,17 @@
         synchronized (mContactsIndexersLocked) {
             ContactsIndexerUserInstance instance = mContactsIndexersLocked.get(userId);
             if (instance == null) {
-                instance = new ContactsIndexerUserInstance(userContext);
-                instance.initialize();
+                File appSearchDir = AppSearchModule.getAppSearchDir(userHandle);
+                File contactsDir = new File(appSearchDir, "contacts");
+                try {
+                    instance = ContactsIndexerUserInstance.createInstance(userContext, contactsDir);
+                    Log.d(TAG,
+                            "Created Contacts Indexer instance for user " + userHandle.toString());
+                } catch (Throwable t) {
+                    Log.e(TAG, "Failed to create Contacts Indexer instance for user "
+                            + userHandle.toString(), t);
+                    return;
+                }
                 mContactsIndexersLocked.put(userId, instance);
             }
             instance.onStart();
diff --git a/service/java/com/android/server/appsearch/contactsindexer/ContactsIndexerUserInstance.java b/service/java/com/android/server/appsearch/contactsindexer/ContactsIndexerUserInstance.java
index 5a8c697..f4a21c6 100644
--- a/service/java/com/android/server/appsearch/contactsindexer/ContactsIndexerUserInstance.java
+++ b/service/java/com/android/server/appsearch/contactsindexer/ContactsIndexerUserInstance.java
@@ -17,16 +17,18 @@
 package com.android.server.appsearch.contactsindexer;
 
 import android.annotation.NonNull;
+import android.annotation.WorkerThread;
+import android.content.ContentResolver;
 import android.content.Context;
 import android.database.ContentObserver;
 import android.net.Uri;
 import android.os.CancellationSignal;
 import android.provider.ContactsContract;
+import android.util.AndroidRuntimeException;
+import android.util.ArraySet;
 import android.util.Log;
 
-import com.android.internal.annotations.GuardedBy;
 import com.android.internal.annotations.VisibleForTesting;
-import com.android.server.appsearch.AppSearchUserInstanceManager;
 
 import java.io.BufferedReader;
 import java.io.BufferedWriter;
@@ -37,6 +39,8 @@
 import java.nio.file.Path;
 import java.util.Collection;
 import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.ExecutionException;
 import java.util.concurrent.ScheduledThreadPoolExecutor;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicBoolean;
@@ -47,7 +51,7 @@
  * <p>It reads the updated/newly-inserted/deleted contacts from CP2, and sync the changes into
  * AppSearch.
  *
- * <p>This class is NOT thread safe.
+ * <p>This class is thread safe.
  *
  * @hide
  */
@@ -57,17 +61,21 @@
 
     private final Context mContext;
     private final ContactsObserver mContactsObserver;
-    private PersistedData mPersistedData;
+    private final PersistedData mPersistedData = new PersistedData();
     // Used for batching/throttling the contact change notification so we won't schedule too many
     // delta updates.
-    private final AtomicBoolean mUpdateScheduled;
-    private final Object mLock = new Object();
+    private final AtomicBoolean mUpdateScheduled = new AtomicBoolean(/*initialValue=*/ false);
+    private final ContactsIndexerImpl mContactsIndexerImpl;
+
+    // Path to persist timestamp data.
+    private final Path mPath;
 
     /**
      * Single executor to make sure there is only one active sync for this {@link
      * ContactsIndexerUserInstance}
      */
     private final ScheduledThreadPoolExecutor mSingleScheduledExecutor;
+
     /**
      * Class to hold the persisted data.
      */
@@ -77,8 +85,8 @@
 
         // Fields need to be serialized.
         private static final int PERSISTED_DATA_VERSION = 1;
-        long mLastDeltaUpdateTimestampMillis = 0;
-        long mLastDeltaDeleteTimestampMillis = 0;
+        volatile long mLastDeltaUpdateTimestampMillis = 0;
+        volatile long mLastDeltaDeleteTimestampMillis = 0;
 
         /**
          * Serializes the fields into a {@link String}.
@@ -118,9 +126,9 @@
             try {
                 int versionNum = Integer.parseInt(fields[0]);
                 if (versionNum < PERSISTED_DATA_VERSION) {
-                    Log.i(TAG, "Read a past version of persisted data.");
+                    Log.d(TAG, "Read a past version of persisted data.");
                 } else if (versionNum > PERSISTED_DATA_VERSION) {
-                    Log.i(TAG, "Read a future version of persisted data.");
+                    Log.d(TAG, "Read a future version of persisted data.");
                 }
             } catch (NumberFormatException e) {
                 throw new IllegalArgumentException(
@@ -143,44 +151,94 @@
     }
 
     /**
+     * Constructs and initializes a {@link ContactsIndexerUserInstance}.
+     *
+     * @param contactsDir data directory for ContactsIndexer.
+     */
+    @WorkerThread
+    @NonNull
+    public static ContactsIndexerUserInstance createInstance(@NonNull Context userContext,
+            @NonNull File contactsDir) throws InterruptedException, ExecutionException {
+        Objects.requireNonNull(userContext);
+        Objects.requireNonNull(contactsDir);
+        if (!contactsDir.exists()) {
+            boolean result = contactsDir.mkdirs();
+            if (!result) {
+                throw new AndroidRuntimeException(
+                        "Failed to create contacts indexer directory " + contactsDir.getPath());
+            }
+        }
+        // We choose to go ahead here even if we can't create the directory. The indexer will
+        // still function correctly except every time the system reboots, the data is not
+        // persisted and reset to default.
+        Path path = new File(contactsDir, CONTACTS_INDEXER_STATE).toPath();
+        ScheduledThreadPoolExecutor singleScheduledExecutor =
+                new ScheduledThreadPoolExecutor(/*corePoolSize=*/ 1);
+        singleScheduledExecutor.setMaximumPoolSize(1);
+        singleScheduledExecutor.setKeepAliveTime(60L, TimeUnit.SECONDS);
+        singleScheduledExecutor.allowCoreThreadTimeOut(true);
+        singleScheduledExecutor.setRemoveOnCancelPolicy(true);
+        // TODO(b/203605504) Check to see if we need a dedicated executor for handling
+        //  AppSearch callbacks. Right now this executor is being used for both schedule delta
+        //  updates, and handle AppSearch callbacks.
+        AppSearchHelper appSearchHelper = AppSearchHelper.createAppSearchHelper(userContext,
+                singleScheduledExecutor);
+        ContactsIndexerImpl contactsIndexerImpl = new ContactsIndexerImpl(userContext,
+                appSearchHelper);
+        ContactsIndexerUserInstance indexer = new ContactsIndexerUserInstance(userContext,
+                contactsIndexerImpl, path, singleScheduledExecutor);
+        indexer.loadPersistedData(path);
+
+        return indexer;
+    }
+
+    /**
      * Constructs a {@link ContactsIndexerUserInstance}.
      *
-     * @param context              Context object passed from
-     *                             {@link com.android.server.appsearch.AppSearchManagerService}
+     * @param context                 Context object passed from
+     *                                {@link ContactsIndexerManagerService}
+     * @param path                    the path to the file to store the meta data for contacts
+     *                                indexer.
+     * @param singleScheduledExecutor a {@link ScheduledThreadPoolExecutor} with at most one
+     *                                executor is expected to ensure the thread safety of this
+     *                                class.
      */
-    public ContactsIndexerUserInstance(@NonNull Context context) {
+    private ContactsIndexerUserInstance(@NonNull Context context,
+            @NonNull ContactsIndexerImpl contactsIndexerImpl, @NonNull Path path,
+            @NonNull ScheduledThreadPoolExecutor singleScheduledExecutor) {
         mContext = Objects.requireNonNull(context);
+        mContactsIndexerImpl = Objects.requireNonNull(contactsIndexerImpl);
+        mPath = Objects.requireNonNull(path);
+        mSingleScheduledExecutor = Objects.requireNonNull(singleScheduledExecutor);
         mContactsObserver = new ContactsObserver();
-        mUpdateScheduled = new AtomicBoolean(/*initialValue=*/ false);
-        mSingleScheduledExecutor = new ScheduledThreadPoolExecutor(/*corePoolSize=*/ 1);
-        mSingleScheduledExecutor.setMaximumPoolSize(1);
-        mSingleScheduledExecutor.setKeepAliveTime(60L, TimeUnit.SECONDS);
-        mSingleScheduledExecutor.allowCoreThreadTimeOut(true);
-        mSingleScheduledExecutor.setRemoveOnCancelPolicy(true);
     }
 
     public void onStart() {
+        Log.d(TAG, "Registering ContactsObserver for " + mContext.getUser());
+
+        // If this contacts indexer instance hasn't synced any CP2 changes into AppSearch,
+        // schedule a one-off task to do a full update. That is, sync all CP2 contacts into
+        // AppSearch.
+        // TODO(b/203605504): Right now this is not thread-safe, and it may read out-dated value.
+        //  But we will rely on last full update timestamp instead anyway in follow-up cls.
+        if (mPersistedData.mLastDeltaUpdateTimestampMillis == 0) {
+            ContactsIndexerMaintenanceService.scheduleOneOffFullUpdateJob(
+                    mContext, mContext.getUser().getIdentifier());
+        }
+
         mContext.getContentResolver()
                 .registerContentObserver(
                         ContactsContract.Contacts.CONTENT_URI,
                         /*notifyForDescendants=*/ true,
                         mContactsObserver);
-        // If this contacts indexer instance hasn't synced any CP2 changes into AppSearch,
-        // schedule a one-off task to do a full update. That is, sync all CP2 contacts into
-        // AppSearch.
-        // TODO(b/203605504): rely on last full update timestmp instead.
-        if (mPersistedData.mLastDeltaUpdateTimestampMillis == 0) {
-            ContactsIndexerMaintenanceService.scheduleOneOffFullUpdateJob(
-                    mContext, mContext.getUser().getIdentifier());
-        }
     }
 
     public void onStop() {
+        Log.d(TAG, "Unregistering ContactsObserver for " + mContext.getUser());
         mContext.getContentResolver().unregisterContentObserver(mContactsObserver);
     }
 
     private class ContactsObserver extends ContentObserver {
-
         public ContactsObserver() {
             super(/*handler=*/ null);
         }
@@ -188,20 +246,42 @@
         @Override
         public void onChange(boolean selfChange, @NonNull Collection<Uri> uris, int flags) {
             if (!selfChange) {
+                int delaySeconds = 2;
+
+                // TODO(b/203605504): make sure that the delta update is scheduled as soon as the
+                //  current sync is completed and not after an arbitrary delay.
+                if (!ContentResolver.getCurrentSyncs().isEmpty()) {
+                    delaySeconds = 30;
+                }
+
                 // TODO(b/203605504): make delay configurable
-                doDeltaUpdate(/*delaySec=*/ 2);
+                scheduleDeltaUpdate(delaySeconds);
             }
         }
     }
 
-    /** Initializes this {@link ContactsIndexerUserInstance}. */
-    public void initialize() {
-        synchronized (mLock) {
-            mPersistedData = loadPersistedDataLocked(
-                    new File(AppSearchUserInstanceManager.getAppSearchDir(
-                            mContext.getUser()),
-                            CONTACTS_INDEXER_STATE).toPath());
-        }
+    /**
+     * Performs a full sync of CP2 contacts to AppSearch builtin:Person corpus.
+     *
+     * @param signal Used to indicate if the full update task should be cancelled.
+     *
+     * TODO(b/203605504):
+     *  1) handle cancellation signal to abort the job
+     *  2) delta update can't delete contacts in AppSearch, which doesn't exist in CP2. A full
+     *               diff might be needed.
+     */
+    public void doFullUpdate(CancellationSignal signal) {
+        // reset the timestamp using singleScheduledExecutor so we don't need a lock for this
+        // non-thread-safe class.
+        mSingleScheduledExecutor.schedule(() -> {
+            mPersistedData.reset();
+        }, /*delay=*/ 0, TimeUnit.SECONDS);
+
+        // Instead of calling doDeltaUpdate directly, we can just schedule a delta update
+        // normally, so mUpdateScheduled can be used to remove unnecessary tasks. If currently
+        // there is a delta update running, this full update might not be run, but next delta update
+        // will run a full update since timestamps are reset above.
+        scheduleDeltaUpdate(/*delay=*/0);
     }
 
     /**
@@ -214,66 +294,74 @@
      * running update, and at most one pending update is queued while the current active update is
      * running.
      */
-    @SuppressWarnings("FutureReturnValueIgnored")
-    // TODO(b/203605504) right now we capture and report the exceptions inside the scheduled task.
-    //  We should revisit this once we have the end-to-end change for Contacts Indexer to see if
-    //  we can remove this suppress.
-    public void doDeltaUpdate(int delaySec) {
+    public void scheduleDeltaUpdate(int delaySec) {
         // We want to batch (trigger only one update) on all Contact Updates for the associated
         // user within the time window(delaySec). And we hope the query to CP2 "Give me all the
         // contacts from timestamp T" would catch all the unhandled contact change notifications.
         if (!mUpdateScheduled.getAndSet(true)) {
             mSingleScheduledExecutor.schedule(() -> {
                 try {
-                    // TODO(b/203605504) once we have the call to do the
-                    //  update, make sure it is reset before doing the update to AppSearch, but
-                    //  after we get the contact changes from CP2. This way, we won't miss any
-                    //  notification in case the update takes a while.
-                    mUpdateScheduled.set(false);
-
-                    // TODO(b/203605504) Simply update and persist those two timestamps for now.
-                    //  1) Querying CP2 and updating AppSearch will be added in the followup
-                    //  changes.
-                    //  2) Reset mUpdateScheduled BEFORE doing the update to allow one pending
-                    //  update queued, so we won't miss any notification while doing the update
-                    //  (It may take some time).
-                    long lastDeltaUpdateTimestampMillis = System.currentTimeMillis();
-                    long lastDeltaDeleteTimestampMillis = System.currentTimeMillis();
-                    synchronized (mLock) {
-                        persistTimestampsLocked(
-                                new File(AppSearchUserInstanceManager.getAppSearchDir(
-                                        mContext.getUser()),
-                                        CONTACTS_INDEXER_STATE).toPath(),
-                                lastDeltaUpdateTimestampMillis,
-                                lastDeltaDeleteTimestampMillis);
-                    }
-                } catch (Exception e) {
-                    Log.e(TAG, "Error during doDeltaUpdate", e);
+                    doDeltaUpdate();
+                } catch (Throwable t) {
+                    Log.e(TAG, "Error during doDeltaUpdate", t);
                 }
             }, delaySec, TimeUnit.SECONDS);
         }
     }
 
     /**
-     * Performs a full sync of CP2 contacts to AppSearch builtin:Person corpus.
-     *
-     * @param signal Used to indicate if the full update task should be cancelled.
-     *
-     * TODO(b/203605504): handle cancellation signal to abort the job
+     * Does the delta update. It also resets {@link ContactsIndexerUserInstance#mUpdateScheduled} to
+     * false.
      */
-    public void doFullUpdate(CancellationSignal signal) {
-        mPersistedData.reset();
-        mUpdateScheduled.set(false);
-        doDeltaUpdate(/*delaySec=*/ 0);
+    @VisibleForTesting
+    // TODO(b/203605504) make this private once we have end to end tests to cover current test
+    //  cases. So it shouldn't be used externally, and it's not thread safe.
+    void doDeltaUpdate() {
+        Log.d(TAG, "previous timestamps -- lastDeltaUpdateTimestampMillis: "
+                + mPersistedData.mLastDeltaUpdateTimestampMillis
+                + " lastDeltaDeleteTimestampMillis: "
+                + mPersistedData.mLastDeltaDeleteTimestampMillis);
+        Set<String> wantedIds = new ArraySet<>();
+        Set<String> unWantedIds = new ArraySet<>();
+        try {
+            mPersistedData.mLastDeltaUpdateTimestampMillis =
+                    ContactsProviderUtil.getUpdatedContactIds(mContext,
+                            mPersistedData.mLastDeltaUpdateTimestampMillis, wantedIds);
+            mPersistedData.mLastDeltaDeleteTimestampMillis =
+                    ContactsProviderUtil.getDeletedContactIds(mContext,
+                            mPersistedData.mLastDeltaDeleteTimestampMillis, unWantedIds);
+            Log.d(TAG, "updated timestamps -- lastDeltaUpdateTimestampMillis: "
+                    + mPersistedData.mLastDeltaUpdateTimestampMillis
+                    + " lastDeltaDeleteTimestampMillis: "
+                    + mPersistedData.mLastDeltaDeleteTimestampMillis);
+        } finally {
+            //  We reset the flag before doing the update to AppSearch, but
+            //  after we get the contact changes from CP2. This way, we won't miss any
+            //  notification in case the update in AppSearch takes a while.
+            mUpdateScheduled.set(false);
+        }
+
+        // Update the person corpus in AppSearch based on the changed contact
+        // information we get from CP2. At this point mUpdateScheduled has been
+        // reset, so a new task is allowed to catch any new changes in CP2.
+        // TODO(b/203605504) report errors here so we can choose not to update the
+        //  timestamps.
+        mContactsIndexerImpl.updatePersonCorpus(wantedIds, unWantedIds);
+
+        // Persist the timestamps.
+        persistTimestamps(mPath, mPersistedData.mLastDeltaUpdateTimestampMillis,
+                mPersistedData.mLastDeltaDeleteTimestampMillis);
     }
 
-    /** Loads the persisted data from disk. */
-    @VisibleForTesting
-    @GuardedBy("mLock")
+    /**
+     * Loads the persisted data from disk.
+     *
+     * <p>It doesn't throw here. If it fails to load file, ContactsIndexer would always use the
+     * timestamps persisted in the memory.
+     */
     @NonNull
-    PersistedData loadPersistedDataLocked(@NonNull Path path) {
+    private void loadPersistedData(@NonNull Path path) {
         Objects.requireNonNull(path);
-        PersistedData persistedData = null;
         boolean isLoadingDataFailed = false;
         try (
                 BufferedReader reader = Files.newBufferedReader(
@@ -282,8 +370,7 @@
         ) {
             // right now we store everything in one line. So we just need to read the first line.
             String content = reader.readLine();
-            persistedData = new PersistedData();
-            persistedData.fromString(content);
+            mPersistedData.fromString(content);
         } catch (IOException e) {
             Log.e(TAG, "Failed to load persisted data from disk.", e);
             isLoadingDataFailed = true;
@@ -291,37 +378,27 @@
             Log.e(TAG, "Failed to parse the loaded data.", e);
             isLoadingDataFailed = true;
         } finally {
-            if (persistedData == null) {
-                // Somehow we can't load the persisted data from disk. It can happen if there is
-                // some I/O error, or a rollback happens, so an older version of ContactsIndexer
-                // would try to read a new version of persisted file. In this case, it is OK for us
-                // to reset those persisted data, and do a full update like what we would
-                // do for the very first time.
-                persistedData = new PersistedData();
-                // TODO(b/203605504) do a full update and set both timestamp be currentTime.
-            } else if (isLoadingDataFailed) {
+            if (isLoadingDataFailed) {
                 // Resets all the values here in case there are some values set from corrupted data.
-                persistedData.reset();
+                mPersistedData.reset();
             }
         }
 
         Log.d(TAG, "Load timestamps from disk: update: "
-                + persistedData.mLastDeltaUpdateTimestampMillis
-                + ", deletion: " + persistedData.mLastDeltaDeleteTimestampMillis);
-
-        return persistedData;
+                + mPersistedData.mLastDeltaUpdateTimestampMillis
+                + ", deletion: " + mPersistedData.mLastDeltaDeleteTimestampMillis);
     }
 
     /** Persists the timestamps to disk. */
-    @VisibleForTesting
-    @GuardedBy("mLock")
-    void persistTimestampsLocked(@NonNull Path path, long lastDeltaUpdateTimestampMillis,
+    private void persistTimestamps(@NonNull Path path, long lastDeltaUpdateTimestampMillis,
             long lastDeltaDeleteTimestampMillis) {
         Objects.requireNonNull(path);
         Objects.requireNonNull(mPersistedData);
 
-        mPersistedData.mLastDeltaUpdateTimestampMillis = lastDeltaUpdateTimestampMillis;
-        mPersistedData.mLastDeltaDeleteTimestampMillis = lastDeltaDeleteTimestampMillis;
+        mPersistedData.mLastDeltaUpdateTimestampMillis =
+                lastDeltaUpdateTimestampMillis;
+        mPersistedData.mLastDeltaDeleteTimestampMillis =
+                lastDeltaDeleteTimestampMillis;
         try (
                 BufferedWriter writer = Files.newBufferedWriter(
                         path,
@@ -330,8 +407,22 @@
             // This would override the previous line. Since we won't delete deprecated fields, we
             // don't need to clear the old content before doing this.
             writer.write(mPersistedData.toString());
+            writer.flush();
         } catch (IOException e) {
             Log.e(TAG, "Failed to persist timestamps for Delta Update on the disk.", e);
         }
     }
-}
+
+    /**
+     * Gets a copy of the current {@link #mPersistedData}. This method is not thread safe, and
+     * should be used for test only.
+     */
+    @VisibleForTesting
+    @NonNull
+    PersistedData getPersistedStateCopy() {
+        PersistedData data = new PersistedData();
+        data.mLastDeltaUpdateTimestampMillis = mPersistedData.mLastDeltaUpdateTimestampMillis;
+        data.mLastDeltaDeleteTimestampMillis = mPersistedData.mLastDeltaDeleteTimestampMillis;
+        return data;
+    }
+}
\ No newline at end of file
diff --git a/service/java/com/android/server/appsearch/contactsindexer/ContactsProviderUtil.java b/service/java/com/android/server/appsearch/contactsindexer/ContactsProviderUtil.java
index be6117f..c94360a 100644
--- a/service/java/com/android/server/appsearch/contactsindexer/ContactsProviderUtil.java
+++ b/service/java/com/android/server/appsearch/contactsindexer/ContactsProviderUtil.java
@@ -76,6 +76,9 @@
         long newTimestamp = sinceFilter;
         Cursor cursor = null;
         try {
+            // TODO(b/203605504) We could optimize the query by setting the sortOrder:
+            //  LAST_DELETED_TIMESTAMP DESC. This way the 1st contact would have the last deleted
+            //  timestamp.
             cursor =
                     context.getContentResolver().query(
                             DeletedContacts.CONTENT_URI,
@@ -197,6 +200,9 @@
         Cursor cursor = null;
         int rows = 0;
         try {
+            // TODO(b/203605504) We could optimize the query by setting the sortOrder:
+            //  LAST_UPDATED_TIMESTAMP DESC. This way the 1st contact would have the last updated
+            //  timestamp.
             cursor =
                     context.getContentResolver().query(
                             contactsUri,
diff --git a/service/java/com/android/server/appsearch/contactsindexer/appsearchtypes/ContactPoint.java b/service/java/com/android/server/appsearch/contactsindexer/appsearchtypes/ContactPoint.java
index 271f285..cbb8986 100644
--- a/service/java/com/android/server/appsearch/contactsindexer/appsearchtypes/ContactPoint.java
+++ b/service/java/com/android/server/appsearch/contactsindexer/appsearchtypes/ContactPoint.java
@@ -35,15 +35,10 @@
     public static final String SCHEMA_TYPE = "builtin:ContactPoint";
 
     // Properties
-    @VisibleForTesting
     public static final String CONTACT_POINT_PROPERTY_LABEL = "label";
-    @VisibleForTesting
     public static final String CONTACT_POINT_PROPERTY_APP_ID = "appId";
-    @VisibleForTesting
     public static final String CONTACT_POINT_PROPERTY_ADDRESS = "address";
-    @VisibleForTesting
     public static final String CONTACT_POINT_PROPERTY_EMAIL = "email";
-    @VisibleForTesting
     public static final String CONTACT_POINT_PROPERTY_TELEPHONE = "telephone";
 
     // Schema
@@ -186,4 +181,4 @@
             return new ContactPoint(super.build());
         }
     }
-}
\ No newline at end of file
+}
diff --git a/service/java/com/android/server/appsearch/contactsindexer/appsearchtypes/Person.java b/service/java/com/android/server/appsearch/contactsindexer/appsearchtypes/Person.java
index f071efe..c713b30 100644
--- a/service/java/com/android/server/appsearch/contactsindexer/appsearchtypes/Person.java
+++ b/service/java/com/android/server/appsearch/contactsindexer/appsearchtypes/Person.java
@@ -46,9 +46,6 @@
     public static final String PERSON_PROPERTY_IS_IMPORTANT = "isImportant";
     public static final String PERSON_PROPERTY_IS_BOT = "isBot";
     public static final String PERSON_PROPERTY_IMAGE_URI = "imageUri";
-
-    // Right now for nested document types, we need to expose the value for the tests to compare.
-    @VisibleForTesting
     public static final String PERSON_PROPERTY_CONTACT_POINT = "contactPoint";
 
     public static final AppSearchSchema SCHEMA = new AppSearchSchema.Builder(SCHEMA_TYPE)
@@ -267,6 +264,7 @@
                     mAdditionalNames.toArray(new String[0]));
             setPropertyDocument(PERSON_PROPERTY_CONTACT_POINT,
                     mContactPoints.toArray(new ContactPoint[0]));
+            // TODO(b/203605504) calculate score here.
             return new Person(super.build());
         }
     }
diff --git a/testing/servicestests/src/com/android/server/appsearch/contactsindexer/AppSearchHelperTest.java b/testing/servicestests/src/com/android/server/appsearch/contactsindexer/AppSearchHelperTest.java
index 54d6eca..90f9acb 100644
--- a/testing/servicestests/src/com/android/server/appsearch/contactsindexer/AppSearchHelperTest.java
+++ b/testing/servicestests/src/com/android/server/appsearch/contactsindexer/AppSearchHelperTest.java
@@ -102,7 +102,7 @@
             ids.add(String.valueOf(i));
         }
 
-        mAppSearchHelper.indexContactsAsync(Arrays.asList(contactData), Runnable::run).get();
+        mAppSearchHelper.indexContactsAsync(Arrays.asList(contactData)).get();
 
         AppSearchBatchResult<String, GenericDocument> result = TestUtils.getDocsByIdAsync(
                 mAppSearchHelper.getSession(),
@@ -125,11 +125,11 @@
         for (int i = 1; i <= contactsExisted; ++i) {
             ids.add(String.valueOf(i));
         }
-        mAppSearchHelper.indexContactsAsync(Arrays.asList(contactData), Runnable::run).get();
+        mAppSearchHelper.indexContactsAsync(Arrays.asList(contactData)).get();
         AppSearchBatchResult<String, GenericDocument> resultBeforeRemove =
                 TestUtils.getDocsByIdAsync(mAppSearchHelper.getSession(), ids, Runnable::run).get();
 
-        mAppSearchHelper.removeContactsByIdAsync(ids, Runnable::run).get();
+        mAppSearchHelper.removeContactsByIdAsync(ids).get();
 
         AppSearchBatchResult<String, GenericDocument> resultAfterRemove =
                 TestUtils.getDocsByIdAsync(mAppSearchHelper.getSession(), ids, Runnable::run).get();
diff --git a/testing/servicestests/src/com/android/server/appsearch/contactsindexer/ContactDataHandlerTest.java b/testing/servicestests/src/com/android/server/appsearch/contactsindexer/ContactDataHandlerTest.java
index 64c896a..2ca8125 100644
--- a/testing/servicestests/src/com/android/server/appsearch/contactsindexer/ContactDataHandlerTest.java
+++ b/testing/servicestests/src/com/android/server/appsearch/contactsindexer/ContactDataHandlerTest.java
@@ -81,6 +81,97 @@
     }
 
     @Test
+    public void testConvertCurrentRowToPerson_labelCustom_typeCustom() {
+        int type = 0; // Custom
+        String name = "name";
+        String address = "emailAddress@google.com";
+        String label = "CustomLabel";
+        ContentValues values = new ContentValues();
+        values.put(Data.MIMETYPE, CommonDataKinds.Email.CONTENT_ITEM_TYPE);
+        values.put(CommonDataKinds.Email.ADDRESS, address);
+        values.put(CommonDataKinds.Email.TYPE, type);
+        values.put(CommonDataKinds.Email.LABEL, label);
+        Cursor cursor = makeCursorFromContentValues(values);
+        Person personExpected = new PersonBuilderHelper(
+                new Person.Builder(TEST_NAMESPACE, TEST_ID, name).setCreationTimestampMillis(
+                        0)).addEmailToPerson(label, address).buildPerson();
+
+        PersonBuilderHelper helperTested = new PersonBuilderHelper(
+                new Person.Builder(TEST_NAMESPACE, TEST_ID,
+                        name).setCreationTimestampMillis(0));
+        convertRowToPerson(cursor, helperTested);
+        Person personTested = helperTested.buildPerson();
+
+        // Since the type is 1(Homes), we won't use user provided label. So it is fine to be null.
+        ContactPoint[] contactPoints = personTested.getContactPoints();
+        assertThat(contactPoints.length).isEqualTo(1);
+        assertThat(contactPoints[0].getLabel()).isEqualTo(label);
+        assertThat(contactPoints[0].getEmails()).asList().containsExactly(address);
+        TestUtils.assertEquals(personTested, personExpected);
+    }
+
+    @Test
+    public void testConvertCurrentRowToPerson_labelIsNull_typeCustom() {
+        int type = 0; // Custom
+        String name = "name";
+        String address = "emailAddress@google.com";
+        ContentValues values = new ContentValues();
+        values.put(Data.MIMETYPE, CommonDataKinds.Email.CONTENT_ITEM_TYPE);
+        values.put(CommonDataKinds.Email.ADDRESS, address);
+        values.put(CommonDataKinds.Email.TYPE, type);
+        // label is not set in the values
+        Cursor cursor = makeCursorFromContentValues(values);
+        // default value for custom label if null is provided by user.
+        String expectedLabel = "Custom";
+        Person personExpected = new PersonBuilderHelper(
+                new Person.Builder(TEST_NAMESPACE, TEST_ID, name).setCreationTimestampMillis(
+                        0)).addEmailToPerson(expectedLabel, address).buildPerson();
+
+        PersonBuilderHelper helperTested = new PersonBuilderHelper(
+                new Person.Builder(TEST_NAMESPACE, TEST_ID,
+                        name).setCreationTimestampMillis(0));
+        convertRowToPerson(cursor, helperTested);
+        Person personTested = helperTested.buildPerson();
+
+        // Since the type is 1(Homes), we won't use user provided label. So it is fine to be null.
+        ContactPoint[] contactPoints = personTested.getContactPoints();
+        assertThat(contactPoints.length).isEqualTo(1);
+        assertThat(contactPoints[0].getLabel()).isEqualTo(expectedLabel);
+        assertThat(contactPoints[0].getEmails()).asList().containsExactly(address);
+        TestUtils.assertEquals(personTested, personExpected);
+    }
+
+    @Test
+    public void testConvertCurrentRowToPerson_labelIsNull_typeHome() {
+        int type = 1; // Home
+        String name = "name";
+        String address = "emailAddress@google.com";
+        String label = "Home";
+        ContentValues values = new ContentValues();
+        values.put(Data.MIMETYPE, CommonDataKinds.Email.CONTENT_ITEM_TYPE);
+        values.put(CommonDataKinds.Email.ADDRESS, address);
+        values.put(CommonDataKinds.Email.TYPE, type);
+        // label is not set in the values
+        Cursor cursor = makeCursorFromContentValues(values);
+        Person personExpected = new PersonBuilderHelper(
+                new Person.Builder(TEST_NAMESPACE, TEST_ID, name).setCreationTimestampMillis(
+                        0)).addEmailToPerson(label, address).buildPerson();
+
+        PersonBuilderHelper helperTested = new PersonBuilderHelper(
+                new Person.Builder(TEST_NAMESPACE, TEST_ID,
+                        name).setCreationTimestampMillis(0));
+        convertRowToPerson(cursor, helperTested);
+        Person personTested = helperTested.buildPerson();
+
+        // Since the type is 1(Homes), we won't use user provided label. So it is fine to be null.
+        ContactPoint[] contactPoints = personTested.getContactPoints();
+        assertThat(contactPoints.length).isEqualTo(1);
+        assertThat(contactPoints[0].getLabel()).isEqualTo(label);
+        assertThat(contactPoints[0].getEmails()).asList().containsExactly(address);
+        TestUtils.assertEquals(personTested, personExpected);
+    }
+
+    @Test
     public void testConvertCurrentRowToPerson_email() {
         int type = 1; // Home
         String name = "name";
@@ -92,12 +183,9 @@
         values.put(CommonDataKinds.Email.TYPE, type);
         values.put(CommonDataKinds.Email.LABEL, label);
         Cursor cursor = makeCursorFromContentValues(values);
-
         Person personExpected = new PersonBuilderHelper(
                 new Person.Builder(TEST_NAMESPACE, TEST_ID, name).setCreationTimestampMillis(
-                        0)).addEmailToPerson(
-                CommonDataKinds.Email.getTypeLabel(mResources, type, label).toString(),
-                address).buildPerson();
+                        0)).addEmailToPerson(label, address).buildPerson();
 
         PersonBuilderHelper helperTested = new PersonBuilderHelper(
                 new Person.Builder(TEST_NAMESPACE, TEST_ID,
diff --git a/testing/servicestests/src/com/android/server/appsearch/contactsindexer/ContactsIndexerImplTest.java b/testing/servicestests/src/com/android/server/appsearch/contactsindexer/ContactsIndexerImplTest.java
index 8c72a78..eed442b 100644
--- a/testing/servicestests/src/com/android/server/appsearch/contactsindexer/ContactsIndexerImplTest.java
+++ b/testing/servicestests/src/com/android/server/appsearch/contactsindexer/ContactsIndexerImplTest.java
@@ -19,6 +19,7 @@
 import static org.mockito.Mockito.doReturn;
 import static org.mockito.Mockito.mock;
 
+import android.annotation.NonNull;
 import android.content.Context;
 import android.media.audio.common.Int;
 import android.provider.ContactsContract;
@@ -41,17 +42,36 @@
 
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Objects;
 import java.util.Set;
 
-// TODO(b/203605504) this is a junit3 tests but we should use junit4. Right now I can't make
+// TODO(b/203605504) this is a junit3 test but we should use junit4. Right now I can't make
 //  ProviderTestRule work so we stick to ProviderTestCase2 for now.
 public class ContactsIndexerImplTest extends ProviderTestCase2<FakeContactsProvider> {
+    // TODO(b/203605504) we could just use AppSearchHelper.
     FakeAppSearchHelper mAppSearchHelper;
 
     public ContactsIndexerImplTest() {
         super(FakeContactsProvider.class, FakeContactsProvider.AUTHORITY);
     }
 
+    private Pair<Long, Long> runDeltaUpdateOnContactsIndexerImpl(
+            @NonNull ContactsIndexerImpl indexerImpl,
+            long lastUpdatedTimestamp,
+            long lastDeletedTimestamp) {
+        Objects.requireNonNull(indexerImpl);
+        Set<String> wantedContactIds = new ArraySet<>();
+        Set<String> unWantedContactIds = new ArraySet<>();
+
+        lastUpdatedTimestamp = ContactsProviderUtil.getUpdatedContactIds(mContext,
+                lastUpdatedTimestamp, wantedContactIds);
+        lastDeletedTimestamp = ContactsProviderUtil.getDeletedContactIds(mContext,
+                lastDeletedTimestamp, unWantedContactIds);
+        indexerImpl.updatePersonCorpus(wantedContactIds, unWantedContactIds);
+
+        return new Pair<>(lastUpdatedTimestamp, lastDeletedTimestamp);
+    }
+
     public void setUp() throws Exception {
         super.setUp();
         mContext = mock(Context.class);
@@ -64,7 +84,7 @@
 
     public void testBatcher_noFlushBeforeReachingLimit() {
         int batchSize = 5;
-        ContactsBatcher batcher = new ContactsBatcher(mAppSearchHelper, batchSize, Runnable::run);
+        ContactsBatcher batcher = new ContactsBatcher(mAppSearchHelper, batchSize);
 
         for (int i = 0; i < batchSize - 1; ++i) {
             batcher.add(new Person.Builder("namespace", /*id=*/ String.valueOf(i), /*name=*/
@@ -76,7 +96,7 @@
 
     public void testBatcher_autoFlush() {
         int batchSize = 5;
-        ContactsBatcher batcher = new ContactsBatcher(mAppSearchHelper, batchSize, Runnable::run);
+        ContactsBatcher batcher = new ContactsBatcher(mAppSearchHelper, batchSize);
 
         for (int i = 0; i < batchSize; ++i) {
             batcher.add(new Person.Builder("namespace", /*id=*/ String.valueOf(i), /*name=*/
@@ -88,7 +108,7 @@
 
     public void testBatcher_batchedContactClearedAfterFlush() {
         int batchSize = 5;
-        ContactsBatcher batcher = new ContactsBatcher(mAppSearchHelper, batchSize, Runnable::run);
+        ContactsBatcher batcher = new ContactsBatcher(mAppSearchHelper, batchSize);
 
         // First batch
         for (int i = 0; i < batchSize; ++i) {
@@ -113,7 +133,7 @@
 
     public void testContactsIndexerImpl_batchRemoveContacts_largerThanBatchSize() {
         ContactsIndexerImpl contactsIndexerImpl = new ContactsIndexerImpl(mContext,
-                mAppSearchHelper, Runnable::run);
+                mAppSearchHelper);
         int totalNum = ContactsIndexerImpl.NUM_DELETED_CONTACTS_PER_BATCH_FOR_APPSEARCH + 1;
         Set<String> removedIds = new ArraySet<>(totalNum);
         for (int i = 0; i < totalNum; ++i) {
@@ -128,7 +148,7 @@
 
     public void testContactsIndexerImpl_batchRemoveContacts_smallerThanBatchSize() {
         ContactsIndexerImpl contactsIndexerImpl = new ContactsIndexerImpl(mContext,
-                mAppSearchHelper, Runnable::run);
+                mAppSearchHelper);
         int totalNum = ContactsIndexerImpl.NUM_DELETED_CONTACTS_PER_BATCH_FOR_APPSEARCH - 1;
         Set<String> removedIds = new ArraySet<>(totalNum);
         for (int i = 0; i < totalNum; ++i) {
@@ -152,7 +172,7 @@
         // 40, [41, 50] needs to be updated. Likewise, if lastDeletedTime is 55, we would delete
         // [56, 100
         ContactsIndexerImpl contactsIndexerImpl = new ContactsIndexerImpl(mContext,
-                mAppSearchHelper, Runnable::run);
+                mAppSearchHelper);
         long lastUpdatedTimestamp = 0;
         long lastDeletedTimestamp = 0;
         int expectedUpdatedContactsFirstId = 1;
@@ -164,8 +184,8 @@
         long expectedNewLastUpdatedTimestamp = FakeContactsProvider.NUM_EXISTED_CONTACTS;
         long expectedNewLastDeletedTimestamp = FakeContactsProvider.NUM_TOTAL_CONTACTS;
 
-        Pair<Long, Long> result = contactsIndexerImpl.doDeltaUpdate(lastUpdatedTimestamp,
-                lastDeletedTimestamp);
+        Pair<Long, Long> result = runDeltaUpdateOnContactsIndexerImpl(contactsIndexerImpl,
+                lastUpdatedTimestamp, lastDeletedTimestamp);
 
         // Check return result
         assertThat(result.first).isEqualTo(expectedNewLastUpdatedTimestamp);
@@ -197,7 +217,7 @@
         // 40, [41, 50] needs to be updated. Likewise, if lastDeletedTime is 55, we would delete
         // [56, 100]
         ContactsIndexerImpl contactsIndexerImpl = new ContactsIndexerImpl(mContext,
-                mAppSearchHelper, Runnable::run);
+                mAppSearchHelper);
         long lastUpdatedTimestamp = FakeContactsProvider.NUM_EXISTED_CONTACTS / 2;
         long lastDeletedTimestamp = FakeContactsProvider.NUM_EXISTED_CONTACTS +
                 (FakeContactsProvider.NUM_TOTAL_CONTACTS
@@ -212,8 +232,8 @@
         long expectedNewLastDeletedTimestamp = FakeContactsProvider.NUM_TOTAL_CONTACTS;
 
 
-        Pair<Long, Long> result = contactsIndexerImpl.doDeltaUpdate(lastUpdatedTimestamp,
-                lastDeletedTimestamp);
+        Pair<Long, Long> result = runDeltaUpdateOnContactsIndexerImpl(contactsIndexerImpl,
+                lastUpdatedTimestamp, lastDeletedTimestamp);
 
         // Check return result
         assertThat(result.first).isEqualTo(expectedNewLastUpdatedTimestamp);
diff --git a/testing/servicestests/src/com/android/server/appsearch/contactsindexer/ContactsIndexerUserInstanceTest.java b/testing/servicestests/src/com/android/server/appsearch/contactsindexer/ContactsIndexerUserInstanceTest.java
new file mode 100644
index 0000000..8026e43
--- /dev/null
+++ b/testing/servicestests/src/com/android/server/appsearch/contactsindexer/ContactsIndexerUserInstanceTest.java
@@ -0,0 +1,268 @@
+/*
+ * Copyright (C) 2022 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.server.appsearch.contactsindexer;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.spy;
+
+import android.app.appsearch.AppSearchManager;
+import android.app.appsearch.AppSearchResult;
+import android.app.appsearch.AppSearchSession;
+import android.app.appsearch.SearchSpec;
+import android.app.appsearch.SetSchemaRequest;
+import android.app.appsearch.SetSchemaResponse;
+import android.content.Context;
+import android.test.ProviderTestCase2;
+import android.util.ArraySet;
+import static org.junit.Assert.assertThrows;
+
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import com.android.server.appsearch.contactsindexer.ContactsIndexerUserInstance.PersistedData;
+import com.android.server.appsearch.contactsindexer.appsearchtypes.Person;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+
+import java.io.BufferedWriter;
+import java.io.File;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+
+// TODO(b/203605504) this is a junit3 test(ProviderTestCase2) but we run it with junit4 to use
+//  some utilities like temporary folder. Right now I can't make ProviderTestRule work so we
+//  stick to ProviderTestCase2 for now.
+@RunWith(AndroidJUnit4.class)
+public class ContactsIndexerUserInstanceTest extends ProviderTestCase2<FakeContactsProvider> {
+    @Rule
+    public TemporaryFolder mTemporaryFolder = new TemporaryFolder();
+
+    private Path mDataFilePath;
+    private SearchSpec mSpecForQueryAllContacts;
+
+    public ContactsIndexerUserInstanceTest() {
+        super(FakeContactsProvider.class, FakeContactsProvider.AUTHORITY);
+    }
+
+    @Before
+    public void setUp() throws Exception {
+        super.setUp();
+
+        // Setup the file path to the persisted data.
+        mDataFilePath = new File(mTemporaryFolder.newFolder(),
+                ContactsIndexerUserInstance.CONTACTS_INDEXER_STATE).toPath();
+
+        Context appContext = ApplicationProvider.getApplicationContext();
+        mContext = spy(appContext);
+        doReturn(getMockContentResolver()).when(mContext).getContentResolver();
+        mSpecForQueryAllContacts = new SearchSpec.Builder().addFilterSchemas(
+                Person.SCHEMA_TYPE).addProjection(Person.SCHEMA_TYPE,
+                Arrays.asList(Person.PERSON_PROPERTY_NAME))
+                .setResultCountPerPage(100)
+                .build();
+    }
+
+    @After
+    public void tearDown() throws Exception {
+        // Wipe the data in AppSearchHelper.DATABASE_NAME.
+        AppSearchManager appSearchManager = mContext.getSystemService(AppSearchManager.class);
+        AppSearchManager.SearchContext searchContext =
+                new AppSearchManager.SearchContext.Builder(AppSearchHelper.DATABASE_NAME).build();
+        CompletableFuture<AppSearchResult<AppSearchSession>> future = new CompletableFuture<>();
+        appSearchManager.createSearchSession(searchContext, Runnable::run, future::complete);
+        AppSearchSession session = future.get().getResultValue();
+        SetSchemaRequest setSchemaRequest = new SetSchemaRequest.Builder()
+                .setForceOverride(true).build();
+        CompletableFuture<AppSearchResult<SetSchemaResponse>> futureSetSchema =
+                new CompletableFuture<>();
+        session.setSchema(setSchemaRequest, Runnable::run, Runnable::run,
+                futureSetSchema::complete);
+    }
+
+    @Test
+    public void testDeltaUpdate_initialLastTimestamps_zero()
+            throws Exception {
+        ContactsIndexerUserInstance instance = ContactsIndexerUserInstance.createInstance(
+                mContext, mDataFilePath.getParent().toFile());
+
+        PersistedData data = instance.getPersistedStateCopy();
+
+        assertThat(data.mLastDeltaUpdateTimestampMillis).isEqualTo(0);
+        assertThat(data.mLastDeltaDeleteTimestampMillis).isEqualTo(0);
+    }
+
+    @Test
+    public void testDeltaUpdate_lastTimestamps_readFromDiskCorrectly()
+            throws Exception {
+        PersistedData newData = new PersistedData();
+        newData.mLastDeltaUpdateTimestampMillis = 1;
+        newData.mLastDeltaDeleteTimestampMillis = 2;
+        clearAndWriteDataToTempFile(newData.toString(), mDataFilePath);
+
+        ContactsIndexerUserInstance instance = ContactsIndexerUserInstance.createInstance(
+                mContext, mDataFilePath.getParent().toFile());
+
+        PersistedData loadedData = instance.getPersistedStateCopy();
+        assertThat(loadedData.mLastDeltaUpdateTimestampMillis).isEqualTo(
+                newData.mLastDeltaUpdateTimestampMillis);
+        assertThat(loadedData.mLastDeltaDeleteTimestampMillis).isEqualTo(
+                newData.mLastDeltaDeleteTimestampMillis);
+    }
+
+    @Test
+    public void testPerUserContactsIndexer_lastTimestamps_writeToDiskCorrectly() throws Exception {
+        // In FakeContactsIndexer, we have contacts array [1, 2, ... NUM_TOTAL_CONTACTS], among
+        // them, [1, NUM_EXISTED_CONTACTS] is for updated contacts, and [NUM_EXISTED_CONTACTS +
+        // 1, NUM_TOTAL_CONTACTS] is for deleted contacts.
+        // And the number here is same for both contact_id, and last update/delete timestamp.
+        // So, if we have [1, 50] for updated, and [51, 100] for deleted, and lastUpdatedTime is
+        // 40, [41, 50] needs to be updated. Likewise, if lastDeletedTime is 55, we would delete
+        // [56, 100
+        long expectedNewLastUpdatedTimestamp = FakeContactsProvider.NUM_EXISTED_CONTACTS;
+        long expectedNewLastDeletedTimestamp = FakeContactsProvider.NUM_TOTAL_CONTACTS;
+        ContactsIndexerUserInstance instance = ContactsIndexerUserInstance.createInstance(
+                mContext, mDataFilePath.getParent().toFile());
+
+        instance.doDeltaUpdate();
+
+        PersistedData loadedData = instance.getPersistedStateCopy();
+        assertThat(loadedData.mLastDeltaUpdateTimestampMillis).isEqualTo(
+                expectedNewLastUpdatedTimestamp);
+        assertThat(loadedData.mLastDeltaDeleteTimestampMillis).isEqualTo(
+                expectedNewLastDeletedTimestamp);
+
+        // Create another indexer to load data from disk.
+        instance = ContactsIndexerUserInstance.createInstance(
+                mContext, mDataFilePath.getParent().toFile());
+        loadedData = instance.getPersistedStateCopy();
+        assertThat(loadedData.mLastDeltaUpdateTimestampMillis).isEqualTo(
+                expectedNewLastUpdatedTimestamp);
+        assertThat(loadedData.mLastDeltaDeleteTimestampMillis).isEqualTo(
+                expectedNewLastDeletedTimestamp);
+    }
+
+    @Test
+    public void testPerUserContactsIndexer_deltaUpdate_firstTime() throws Exception {
+        // In FakeContactsIndexer, we have contacts array [1, 2, ... NUM_TOTAL_CONTACTS], among
+        // them, [1, NUM_EXISTED_CONTACTS] is for updated contacts, and [NUM_EXISTED_CONTACTS +
+        // 1, NUM_TOTAL_CONTACTS] is for deleted contacts.
+        // And the number here is same for both contact_id, and last update/delete timestamp.
+        // So, if we have [1, 50] for updated, and [51, 100] for deleted, and lastUpdatedTime is
+        // 40, [41, 50] needs to be updated. Likewise, if lastDeletedTime is 55, we would delete
+        // [56, 100
+        int expectedUpdatedContactsFirstId = 1;
+        int expectedUpdatedContactsLastId = FakeContactsProvider.NUM_EXISTED_CONTACTS;
+        long expectedNewLastUpdatedTimestamp = FakeContactsProvider.NUM_EXISTED_CONTACTS;
+        long expectedNewLastDeletedTimestamp = FakeContactsProvider.NUM_TOTAL_CONTACTS;
+        ContactsIndexerUserInstance instance = ContactsIndexerUserInstance.createInstance(
+                mContext, mDataFilePath.getParent().toFile());
+
+        instance.doDeltaUpdate();
+
+        PersistedData loadedData = instance.getPersistedStateCopy();
+        assertThat(loadedData.mLastDeltaUpdateTimestampMillis).isEqualTo(
+                expectedNewLastUpdatedTimestamp);
+        assertThat(loadedData.mLastDeltaDeleteTimestampMillis).isEqualTo(
+                expectedNewLastDeletedTimestamp);
+
+        AppSearchHelper appSearchHelper = AppSearchHelper.createAppSearchHelper(mContext,
+                Runnable::run);
+        Set<String> expectedIds = new ArraySet<>();
+        for (int i = expectedUpdatedContactsFirstId; i <= expectedUpdatedContactsLastId; ++i) {
+            expectedIds.add(String.valueOf(i));
+        }
+
+        Set<String> actualIds = TestUtils.getDocIdsByQuery(
+                appSearchHelper.getSession(),
+                /*query=*/"",
+                mSpecForQueryAllContacts,
+                Runnable::run);
+
+        assertThat(actualIds).isEqualTo(expectedIds);
+    }
+
+    @Test
+    public void testPerUserContactsIndexer_deltaUpdate() throws Exception {
+        // In FakeContactsIndexer, we have contacts array [1, 2, ... NUM_TOTAL_CONTACTS], among
+        // them, [1, NUM_EXISTED_CONTACTS] is for updated contacts, and [NUM_EXISTED_CONTACTS +
+        // 1, NUM_TOTAL_CONTACTS] is for deleted contacts.
+        // And the number here is same for both contact_id, and last update/delete timestamp.
+        // So, if we have [1, 50] for updated, and [51, 100] for deleted, and lastUpdatedTime is
+        // 40, [41, 50] needs to be updated. Likewise, if lastDeletedTime is 55, we would delete
+        // [56, 100
+        int lastUpdatedTimestamp = 19;
+        int lastDeletedTimestamp = FakeContactsProvider.NUM_EXISTED_CONTACTS + 1;
+        int expectedUpdatedContactsFirstId = lastUpdatedTimestamp + 1;
+        int expectedUpdatedContactsLastId = FakeContactsProvider.NUM_EXISTED_CONTACTS;
+        long expectedNewLastUpdatedTimestamp = FakeContactsProvider.NUM_EXISTED_CONTACTS;
+        long expectedNewLastDeletedTimestamp = FakeContactsProvider.NUM_TOTAL_CONTACTS;
+        PersistedData persistedData = new PersistedData();
+        persistedData.mLastDeltaUpdateTimestampMillis = lastUpdatedTimestamp;
+        persistedData.mLastDeltaDeleteTimestampMillis = lastDeletedTimestamp;
+        clearAndWriteDataToTempFile(persistedData.toString(), mDataFilePath);
+        ContactsIndexerUserInstance instance = ContactsIndexerUserInstance.createInstance(
+                mContext, mDataFilePath.getParent().toFile());
+
+        instance.doDeltaUpdate();
+
+        PersistedData loadedData = instance.getPersistedStateCopy();
+        assertThat(loadedData.mLastDeltaUpdateTimestampMillis).isEqualTo(
+                expectedNewLastUpdatedTimestamp);
+        assertThat(loadedData.mLastDeltaDeleteTimestampMillis).isEqualTo(
+                expectedNewLastDeletedTimestamp);
+
+        AppSearchHelper appSearchHelper = AppSearchHelper.createAppSearchHelper(mContext,
+                Runnable::run);
+        Set<String> expectedIds = new ArraySet<>();
+        for (int i = expectedUpdatedContactsFirstId; i <= expectedUpdatedContactsLastId; ++i) {
+            expectedIds.add(String.valueOf(i));
+        }
+
+        Set<String> actualIds = TestUtils.getDocIdsByQuery(
+                appSearchHelper.getSession(),
+                /*query=*/ "",
+                mSpecForQueryAllContacts,
+                Runnable::run);
+
+        assertThat(actualIds).isEqualTo(expectedIds);
+    }
+
+    private void clearAndWriteDataToTempFile(String data, Path dataFilePath) throws IOException {
+        try (
+                BufferedWriter writer = Files.newBufferedWriter(
+                        dataFilePath,
+                        StandardCharsets.UTF_8);
+        ) {
+            // This would override the previous line. Since we won't delete deprecated fields, we
+            // don't need to clear the old content before doing this.
+            writer.write(data);
+            writer.flush();
+        }
+    }
+}
diff --git a/testing/servicestests/src/com/android/server/appsearch/contactsindexer/ContactsProviderUtilTest.java b/testing/servicestests/src/com/android/server/appsearch/contactsindexer/ContactsProviderUtilTest.java
index c241a3d..499c5c7 100644
--- a/testing/servicestests/src/com/android/server/appsearch/contactsindexer/ContactsProviderUtilTest.java
+++ b/testing/servicestests/src/com/android/server/appsearch/contactsindexer/ContactsProviderUtilTest.java
@@ -29,10 +29,9 @@
 
 import java.util.Set;
 
-// TODO(b/203605504) this is a junit3 tests but we should use junit4. Right now I can't make
+// TODO(b/203605504) this is a junit3 test but we should use junit4. Right now I can't make
 //  ProviderTestRule work so we stick to ProviderTestCase2 for now.
 public class ContactsProviderUtilTest extends ProviderTestCase2<FakeContactsProvider> {
-
     public ContactsProviderUtilTest() {
         super(FakeContactsProvider.class, FakeContactsProvider.AUTHORITY);
     }
diff --git a/testing/servicestests/src/com/android/server/appsearch/contactsindexer/FakeAppSearchHelper.java b/testing/servicestests/src/com/android/server/appsearch/contactsindexer/FakeAppSearchHelper.java
index d83165f..529f59d 100644
--- a/testing/servicestests/src/com/android/server/appsearch/contactsindexer/FakeAppSearchHelper.java
+++ b/testing/servicestests/src/com/android/server/appsearch/contactsindexer/FakeAppSearchHelper.java
@@ -30,7 +30,7 @@
 
 public final class FakeAppSearchHelper extends AppSearchHelper {
     public FakeAppSearchHelper(@NonNull Context context) {
-        super(context);
+        super(context, Runnable::run);
     }
 
     List<String> mRemovedIds = new ArrayList<>();
@@ -42,8 +42,7 @@
     }
 
     @Override
-    public CompletableFuture<Void> indexContactsAsync(@NonNull Collection<Person> contacts,
-            @NonNull Executor executor) {
+    public CompletableFuture<Void> indexContactsAsync(@NonNull Collection<Person> contacts) {
         mIndexedContacts.addAll(contacts);
         CompletableFuture<Void> future = new CompletableFuture<>();
         future.complete(null);
@@ -51,8 +50,7 @@
     }
 
     @Override
-    public CompletableFuture<Void> removeContactsByIdAsync(@NonNull Collection<String> ids,
-            @NonNull Executor executor) {
+    public CompletableFuture<Void> removeContactsByIdAsync(@NonNull Collection<String> ids) {
         mRemovedIds.addAll(ids);
         CompletableFuture<Void> future = new CompletableFuture<>();
         future.complete(null);