Move coalescing logic out of AnnotatedCallLogContentProvider.

Bug: 79232964
Test: CoalescerTest, AnnotatedCallLogCursorLoaderTest, and manual testing.
PiperOrigin-RevId: 196321995
Change-Id: I016bf28e0c09cf4fee5bc5a9115335fb35b7f7e9
diff --git a/java/com/android/dialer/calllog/database/AnnotatedCallLogContentProvider.java b/java/com/android/dialer/calllog/database/AnnotatedCallLogContentProvider.java
index 7fc474a..3ca76ee 100644
--- a/java/com/android/dialer/calllog/database/AnnotatedCallLogContentProvider.java
+++ b/java/com/android/dialer/calllog/database/AnnotatedCallLogContentProvider.java
@@ -29,16 +29,12 @@
 import android.database.sqlite.SQLiteQueryBuilder;
 import android.net.Uri;
 import android.os.Build;
-import android.provider.CallLog.Calls;
 import android.support.annotation.NonNull;
 import android.support.annotation.Nullable;
 import com.android.dialer.calllog.database.contract.AnnotatedCallLogContract;
 import com.android.dialer.calllog.database.contract.AnnotatedCallLogContract.AnnotatedCallLog;
-import com.android.dialer.calllog.database.contract.AnnotatedCallLogContract.CoalescedAnnotatedCallLog;
 import com.android.dialer.common.Assert;
 import com.android.dialer.common.LogUtil;
-import com.android.dialer.metrics.Metrics;
-import com.android.dialer.metrics.MetricsComponent;
 import java.util.ArrayList;
 import java.util.Arrays;
 
@@ -50,7 +46,6 @@
   private static final int ANNOTATED_CALL_LOG_TABLE_CODE = 1;
   private static final int ANNOTATED_CALL_LOG_TABLE_ID_CODE = 2;
   private static final int ANNOTATED_CALL_LOG_TABLE_DISTINCT_NUMBER_CODE = 3;
-  private static final int COALESCED_ANNOTATED_CALL_LOG_TABLE_CODE = 4;
 
   private static final UriMatcher uriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
 
@@ -65,10 +60,6 @@
         AnnotatedCallLogContract.AUTHORITY,
         AnnotatedCallLog.DISTINCT_PHONE_NUMBERS,
         ANNOTATED_CALL_LOG_TABLE_DISTINCT_NUMBER_CODE);
-    uriMatcher.addURI(
-        AnnotatedCallLogContract.AUTHORITY,
-        CoalescedAnnotatedCallLog.TABLE,
-        COALESCED_ANNOTATED_CALL_LOG_TABLE_CODE);
   }
 
   private AnnotatedCallLogDatabaseHelper databaseHelper;
@@ -142,33 +133,6 @@
           LogUtil.w("AnnotatedCallLogContentProvider.query", "cursor was null");
         }
         return cursor;
-      case COALESCED_ANNOTATED_CALL_LOG_TABLE_CODE:
-        Assert.checkArgument(
-            projection == CoalescedAnnotatedCallLog.ALL_COLUMNS,
-            "only ALL_COLUMNS projection supported for coalesced call log");
-        Assert.checkArgument(selection == null, "selection not supported for coalesced call log");
-        Assert.checkArgument(
-            selectionArgs == null, "selection args not supported for coalesced call log");
-        Assert.checkArgument(sortOrder == null, "sort order not supported for coalesced call log");
-        MetricsComponent.get(getContext()).metrics().startTimer(Metrics.NEW_CALL_LOG_COALESCE);
-        try (Cursor allAnnotatedCallLogRows =
-            queryBuilder.query(
-                db,
-                null,
-                String.format("%s != ?", CoalescedAnnotatedCallLog.CALL_TYPE),
-                new String[] {Integer.toString(Calls.VOICEMAIL_TYPE)},
-                null,
-                null,
-                AnnotatedCallLog.TIMESTAMP + " DESC")) {
-          Cursor coalescedRows =
-              CallLogDatabaseComponent.get(getContext())
-                  .coalescer()
-                  .coalesce(allAnnotatedCallLogRows);
-          coalescedRows.setNotificationUri(
-              getContext().getContentResolver(), CoalescedAnnotatedCallLog.CONTENT_URI);
-          MetricsComponent.get(getContext()).metrics().stopTimer(Metrics.NEW_CALL_LOG_COALESCE);
-          return coalescedRows;
-        }
       default:
         throw new IllegalArgumentException("Unknown uri: " + uri);
     }
@@ -207,8 +171,6 @@
         break;
       case ANNOTATED_CALL_LOG_TABLE_DISTINCT_NUMBER_CODE:
         throw new UnsupportedOperationException();
-      case COALESCED_ANNOTATED_CALL_LOG_TABLE_CODE:
-        throw new UnsupportedOperationException("coalesced call log does not support inserting");
       default:
         throw new IllegalArgumentException("Unknown uri: " + uri);
     }
@@ -245,8 +207,6 @@
         break;
       case ANNOTATED_CALL_LOG_TABLE_DISTINCT_NUMBER_CODE:
         throw new UnsupportedOperationException();
-      case COALESCED_ANNOTATED_CALL_LOG_TABLE_CODE:
-        throw new UnsupportedOperationException("coalesced call log does not support deleting");
       default:
         throw new IllegalArgumentException("Unknown uri: " + uri);
     }
@@ -300,7 +260,6 @@
         }
         return rows;
       case ANNOTATED_CALL_LOG_TABLE_DISTINCT_NUMBER_CODE:
-      case COALESCED_ANNOTATED_CALL_LOG_TABLE_CODE:
         throw new UnsupportedOperationException();
       default:
         throw new IllegalArgumentException("Unknown uri: " + uri);
@@ -336,9 +295,6 @@
             break;
           case ANNOTATED_CALL_LOG_TABLE_DISTINCT_NUMBER_CODE:
             throw new UnsupportedOperationException();
-          case COALESCED_ANNOTATED_CALL_LOG_TABLE_CODE:
-            throw new UnsupportedOperationException(
-                "coalesced call log does not support applyBatch");
           default:
             throw new IllegalArgumentException("Unknown uri: " + operation.getUri());
         }
@@ -380,10 +336,6 @@
   }
 
   private void notifyChange(Uri uri) {
-    getContext().getContentResolver().notifyChange(uri, null);
-    // Any time the annotated call log changes, we need to also notify observers of the
-    // CoalescedAnnotatedCallLog, since that is just a massaged in-memory view of the real annotated
-    // call log table.
-    getContext().getContentResolver().notifyChange(CoalescedAnnotatedCallLog.CONTENT_URI, null);
+    getContext().getContentResolver().notifyChange(uri, /* observer = */ null);
   }
 }
diff --git a/java/com/android/dialer/calllog/database/Coalescer.java b/java/com/android/dialer/calllog/database/Coalescer.java
index 8a16be2..a889b9f 100644
--- a/java/com/android/dialer/calllog/database/Coalescer.java
+++ b/java/com/android/dialer/calllog/database/Coalescer.java
@@ -22,17 +22,25 @@
 import android.support.annotation.NonNull;
 import android.support.annotation.WorkerThread;
 import android.telecom.PhoneAccountHandle;
+import android.text.TextUtils;
 import com.android.dialer.CoalescedIds;
 import com.android.dialer.DialerPhoneNumber;
+import com.android.dialer.NumberAttributes;
 import com.android.dialer.calllog.database.contract.AnnotatedCallLogContract.AnnotatedCallLog;
 import com.android.dialer.calllog.database.contract.AnnotatedCallLogContract.CoalescedAnnotatedCallLog;
 import com.android.dialer.calllog.datasources.CallLogDataSource;
 import com.android.dialer.calllog.datasources.DataSources;
+import com.android.dialer.calllog.model.CoalescedRow;
 import com.android.dialer.common.Assert;
+import com.android.dialer.common.concurrent.Annotations.BackgroundExecutor;
 import com.android.dialer.compat.telephony.TelephonyManagerCompat;
+import com.android.dialer.metrics.FutureTimer;
+import com.android.dialer.metrics.Metrics;
 import com.android.dialer.phonenumberproto.DialerPhoneNumberUtil;
 import com.android.dialer.telecom.TelecomUtil;
 import com.google.common.base.Preconditions;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.protobuf.InvalidProtocolBufferException;
 import java.util.ArrayList;
 import java.util.List;
@@ -41,32 +49,76 @@
 import javax.inject.Inject;
 
 /**
- * Coalesces call log rows by combining some adjacent rows.
+ * Coalesces rows in {@link AnnotatedCallLog} by combining adjacent rows.
  *
  * <p>Applies the logic that determines which adjacent rows should be coalesced, and then delegates
  * to each data source to determine how individual columns should be aggregated.
  */
 public class Coalescer {
+
+  // Indexes for CoalescedAnnotatedCallLog.ALL_COLUMNS
+  private static final int ID = 0;
+  private static final int TIMESTAMP = 1;
+  private static final int NUMBER = 2;
+  private static final int FORMATTED_NUMBER = 3;
+  private static final int NUMBER_PRESENTATION = 4;
+  private static final int IS_READ = 5;
+  private static final int NEW = 6;
+  private static final int GEOCODED_LOCATION = 7;
+  private static final int PHONE_ACCOUNT_COMPONENT_NAME = 8;
+  private static final int PHONE_ACCOUNT_ID = 9;
+  private static final int FEATURES = 10;
+  private static final int NUMBER_ATTRIBUTES = 11;
+  private static final int IS_VOICEMAIL_CALL = 12;
+  private static final int VOICEMAIL_CALL_TAG = 13;
+  private static final int CALL_TYPE = 14;
+  private static final int COALESCED_IDS = 15;
+
   private final DataSources dataSources;
+  private final FutureTimer futureTimer;
+  private final ListeningExecutorService backgroundExecutorService;
 
   @Inject
-  Coalescer(DataSources dataSources) {
+  Coalescer(
+      @BackgroundExecutor ListeningExecutorService backgroundExecutorService,
+      DataSources dataSources,
+      FutureTimer futureTimer) {
+    this.backgroundExecutorService = backgroundExecutorService;
     this.dataSources = dataSources;
+    this.futureTimer = futureTimer;
   }
 
   /**
-   * Reads the entire {@link AnnotatedCallLog} database into memory from the provided {@code
-   * allAnnotatedCallLog} parameter and then builds and returns a new {@link MatrixCursor} which is
-   * the result of combining adjacent rows which should be collapsed for display purposes.
+   * Given rows from {@link AnnotatedCallLog}, combine adjacent ones which should be collapsed for
+   * display purposes.
    *
-   * @param allAnnotatedCallLogRowsSortedByTimestampDesc all {@link AnnotatedCallLog} rows, sorted
-   *     by timestamp descending
+   * @param allAnnotatedCallLogRowsSortedByTimestampDesc {@link AnnotatedCallLog} rows sorted in
+   *     descending order of timestamp.
+   * @return a future of a {@link MatrixCursor} containing the {@link CoalescedAnnotatedCallLog}
+   *     rows to display
+   */
+  public ListenableFuture<Cursor> coalesce(
+      @NonNull Cursor allAnnotatedCallLogRowsSortedByTimestampDesc) {
+    ListenableFuture<Cursor> coalescingFuture =
+        backgroundExecutorService.submit(
+            () -> coalesceInternal(Assert.isNotNull(allAnnotatedCallLogRowsSortedByTimestampDesc)));
+    futureTimer.applyTiming(coalescingFuture, Metrics.NEW_CALL_LOG_COALESCE);
+    return coalescingFuture;
+  }
+
+  /**
+   * Reads the entire {@link AnnotatedCallLog} into memory from the provided cursor and then builds
+   * and returns a new {@link MatrixCursor} of {@link CoalescedAnnotatedCallLog}, which is the
+   * result of combining adjacent rows which should be collapsed for display purposes.
+   *
+   * @param allAnnotatedCallLogRowsSortedByTimestampDesc {@link AnnotatedCallLog} rows sorted in
+   *     descending order of timestamp.
    * @return a new {@link MatrixCursor} containing the {@link CoalescedAnnotatedCallLog} rows to
    *     display
    */
   @WorkerThread
   @NonNull
-  Cursor coalesce(@NonNull Cursor allAnnotatedCallLogRowsSortedByTimestampDesc) {
+  private Cursor coalesceInternal(Cursor allAnnotatedCallLogRowsSortedByTimestampDesc) {
     Assert.isWorkerThread();
 
     // Note: This method relies on rowsShouldBeCombined to determine which rows should be combined,
@@ -77,7 +129,7 @@
     MatrixCursor allCoalescedRowsMatrixCursor =
         new MatrixCursor(
             CoalescedAnnotatedCallLog.ALL_COLUMNS,
-            Assert.isNotNull(allAnnotatedCallLogRowsSortedByTimestampDesc).getCount());
+            allAnnotatedCallLogRowsSortedByTimestampDesc.getCount());
 
     if (!allAnnotatedCallLogRowsSortedByTimestampDesc.moveToFirst()) {
       return allCoalescedRowsMatrixCursor;
@@ -252,4 +304,85 @@
       rowBuilder.add(entry.getKey(), entry.getValue());
     }
   }
+
+  /**
+   * Creates a new {@link CoalescedRow} based on the data at the provided cursor's current position.
+   *
+   * <p>The provided cursor should be one for {@link CoalescedAnnotatedCallLog}.
+   */
+  public static CoalescedRow toRow(Cursor coalescedAnnotatedCallLogCursor) {
+    DialerPhoneNumber number;
+    try {
+      number = DialerPhoneNumber.parseFrom(coalescedAnnotatedCallLogCursor.getBlob(NUMBER));
+    } catch (InvalidProtocolBufferException e) {
+      throw new IllegalStateException("Couldn't parse DialerPhoneNumber bytes");
+    }
+
+    CoalescedIds coalescedIds;
+    try {
+      coalescedIds = CoalescedIds.parseFrom(coalescedAnnotatedCallLogCursor.getBlob(COALESCED_IDS));
+    } catch (InvalidProtocolBufferException e) {
+      throw new IllegalStateException("Couldn't parse CoalescedIds bytes");
+    }
+
+    NumberAttributes numberAttributes;
+    try {
+      numberAttributes =
+          NumberAttributes.parseFrom(coalescedAnnotatedCallLogCursor.getBlob(NUMBER_ATTRIBUTES));
+    } catch (InvalidProtocolBufferException e) {
+      throw new IllegalStateException("Couldn't parse NumberAttributes bytes");
+    }
+
+    CoalescedRow.Builder coalescedRowBuilder =
+        CoalescedRow.newBuilder()
+            .setId(coalescedAnnotatedCallLogCursor.getLong(ID))
+            .setTimestamp(coalescedAnnotatedCallLogCursor.getLong(TIMESTAMP))
+            .setNumber(number)
+            .setNumberPresentation(coalescedAnnotatedCallLogCursor.getInt(NUMBER_PRESENTATION))
+            .setIsRead(coalescedAnnotatedCallLogCursor.getInt(IS_READ) == 1)
+            .setIsNew(coalescedAnnotatedCallLogCursor.getInt(NEW) == 1)
+            .setFeatures(coalescedAnnotatedCallLogCursor.getInt(FEATURES))
+            .setCallType(coalescedAnnotatedCallLogCursor.getInt(CALL_TYPE))
+            .setNumberAttributes(numberAttributes)
+            .setIsVoicemailCall(coalescedAnnotatedCallLogCursor.getInt(IS_VOICEMAIL_CALL) == 1)
+            .setCoalescedIds(coalescedIds);
+
+    String formattedNumber = coalescedAnnotatedCallLogCursor.getString(FORMATTED_NUMBER);
+    if (!TextUtils.isEmpty(formattedNumber)) {
+      coalescedRowBuilder.setFormattedNumber(formattedNumber);
+    }
+
+    String geocodedLocation = coalescedAnnotatedCallLogCursor.getString(GEOCODED_LOCATION);
+    if (!TextUtils.isEmpty(geocodedLocation)) {
+      coalescedRowBuilder.setGeocodedLocation(geocodedLocation);
+    }
+
+    String phoneAccountComponentName =
+        coalescedAnnotatedCallLogCursor.getString(PHONE_ACCOUNT_COMPONENT_NAME);
+    if (!TextUtils.isEmpty(phoneAccountComponentName)) {
+      coalescedRowBuilder.setPhoneAccountComponentName(
+          coalescedAnnotatedCallLogCursor.getString(PHONE_ACCOUNT_COMPONENT_NAME));
+    }
+
+    String phoneAccountId = coalescedAnnotatedCallLogCursor.getString(PHONE_ACCOUNT_ID);
+    if (!TextUtils.isEmpty(phoneAccountId)) {
+      coalescedRowBuilder.setPhoneAccountId(phoneAccountId);
+    }
+
+    String voicemailCallTag = coalescedAnnotatedCallLogCursor.getString(VOICEMAIL_CALL_TAG);
+    if (!TextUtils.isEmpty(voicemailCallTag)) {
+      coalescedRowBuilder.setVoicemailCallTag(voicemailCallTag);
+    }
+
+    return coalescedRowBuilder.build();
+  }
+
+  /**
+   * Returns the timestamp at the provided cursor's current position.
+   *
+   * <p>The provided cursor should be one for {@link CoalescedAnnotatedCallLog}.
+   */
+  public static long getTimestamp(Cursor coalescedAnnotatedCallLogCursor) {
+    return coalescedAnnotatedCallLogCursor.getLong(TIMESTAMP);
+  }
 }
diff --git a/java/com/android/dialer/calllog/database/contract/AnnotatedCallLogContract.java b/java/com/android/dialer/calllog/database/contract/AnnotatedCallLogContract.java
index ee888d1..8ca151c 100644
--- a/java/com/android/dialer/calllog/database/contract/AnnotatedCallLogContract.java
+++ b/java/com/android/dialer/calllog/database/contract/AnnotatedCallLogContract.java
@@ -225,7 +225,7 @@
     /**
      * An unique id to associate this call log row to a {@link android.telecom.Call}.
      *
-     * <p>For pre-Q device, this is same as {@link TIMESTAMP}.
+     * <p>For pre-Q device, this is same as {@link #TIMESTAMP}.
      *
      * <p>For Q+ device, this will be copied from {@link android.provider.CallLog.Calls}.
      *
@@ -244,16 +244,6 @@
    */
   public static final class CoalescedAnnotatedCallLog implements CommonColumns {
 
-    public static final String TABLE = "CoalescedAnnotatedCallLog";
-
-    /** The content URI for this table. */
-    public static final Uri CONTENT_URI =
-        Uri.withAppendedPath(AnnotatedCallLogContract.CONTENT_URI, TABLE);
-
-    /** The MIME type of a {@link android.content.ContentProvider#getType(Uri)} single entry. */
-    public static final String CONTENT_ITEM_TYPE =
-        "vnd.android.cursor.item/coalesced_annotated_call_log";
-
     /**
      * IDs of rows in {@link AnnotatedCallLog} that are coalesced into one row in {@link
      * CoalescedAnnotatedCallLog}, encoded as a {@link com.android.dialer.CoalescedIds} proto.
diff --git a/java/com/android/dialer/calllog/ui/AnnotatedCallLogCursorLoader.java b/java/com/android/dialer/calllog/ui/AnnotatedCallLogCursorLoader.java
new file mode 100644
index 0000000..6111cd8
--- /dev/null
+++ b/java/com/android/dialer/calllog/ui/AnnotatedCallLogCursorLoader.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright (C) 2018 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.dialer.calllog.ui;
+
+import android.content.Context;
+import android.provider.CallLog.Calls;
+import android.support.v4.content.CursorLoader;
+import com.android.dialer.calllog.database.contract.AnnotatedCallLogContract.AnnotatedCallLog;
+
+/** Cursor loader for {@link AnnotatedCallLog}. */
+final class AnnotatedCallLogCursorLoader extends CursorLoader {
+
+  AnnotatedCallLogCursorLoader(Context context) {
+    super(
+        context,
+        AnnotatedCallLog.CONTENT_URI,
+        /* projection = */ null,
+        /* selection = */ AnnotatedCallLog.CALL_TYPE + " != ?",
+        /* selectionArgs = */ new String[] {Integer.toString(Calls.VOICEMAIL_TYPE)},
+        /* sortOrder = */ AnnotatedCallLog.TIMESTAMP + " DESC");
+  }
+}
diff --git a/java/com/android/dialer/calllog/ui/CoalescedAnnotatedCallLogCursorLoader.java b/java/com/android/dialer/calllog/ui/CoalescedAnnotatedCallLogCursorLoader.java
deleted file mode 100644
index 164bb7d..0000000
--- a/java/com/android/dialer/calllog/ui/CoalescedAnnotatedCallLogCursorLoader.java
+++ /dev/null
@@ -1,132 +0,0 @@
-/*
- * Copyright (C) 2017 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.dialer.calllog.ui;
-
-import android.content.Context;
-import android.database.Cursor;
-import android.support.v4.content.CursorLoader;
-import android.text.TextUtils;
-import com.android.dialer.CoalescedIds;
-import com.android.dialer.DialerPhoneNumber;
-import com.android.dialer.NumberAttributes;
-import com.android.dialer.calllog.database.contract.AnnotatedCallLogContract.CoalescedAnnotatedCallLog;
-import com.android.dialer.calllog.model.CoalescedRow;
-import com.google.protobuf.InvalidProtocolBufferException;
-
-/** CursorLoader for the coalesced annotated call log. */
-final class CoalescedAnnotatedCallLogCursorLoader extends CursorLoader {
-
-  // Indexes for CoalescedAnnotatedCallLog.ALL_COLUMNS
-  private static final int ID = 0;
-  private static final int TIMESTAMP = 1;
-  private static final int NUMBER = 2;
-  private static final int FORMATTED_NUMBER = 3;
-  private static final int NUMBER_PRESENTATION = 4;
-  private static final int IS_READ = 5;
-  private static final int NEW = 6;
-  private static final int GEOCODED_LOCATION = 7;
-  private static final int PHONE_ACCOUNT_COMPONENT_NAME = 8;
-  private static final int PHONE_ACCOUNT_ID = 9;
-  private static final int FEATURES = 10;
-  private static final int NUMBER_ATTRIBUTES = 11;
-  private static final int IS_VOICEMAIL_CALL = 12;
-  private static final int VOICEMAIL_CALL_TAG = 13;
-  private static final int CALL_TYPE = 14;
-  private static final int COALESCED_IDS = 15;
-
-  CoalescedAnnotatedCallLogCursorLoader(Context context) {
-    // CoalescedAnnotatedCallLog requires that PROJECTION be ALL_COLUMNS and the following params be
-    // null.
-    super(
-        context,
-        CoalescedAnnotatedCallLog.CONTENT_URI,
-        CoalescedAnnotatedCallLog.ALL_COLUMNS,
-        null,
-        null,
-        null);
-  }
-
-  /** Creates a new {@link CoalescedRow} from the provided cursor using the current position. */
-  static CoalescedRow toRow(Cursor cursor) {
-    DialerPhoneNumber number;
-    try {
-      number = DialerPhoneNumber.parseFrom(cursor.getBlob(NUMBER));
-    } catch (InvalidProtocolBufferException e) {
-      throw new IllegalStateException("Couldn't parse DialerPhoneNumber bytes");
-    }
-
-    CoalescedIds coalescedIds;
-    try {
-      coalescedIds = CoalescedIds.parseFrom(cursor.getBlob(COALESCED_IDS));
-    } catch (InvalidProtocolBufferException e) {
-      throw new IllegalStateException("Couldn't parse CoalescedIds bytes");
-    }
-
-    NumberAttributes numberAttributes;
-    try {
-      numberAttributes = NumberAttributes.parseFrom(cursor.getBlob(NUMBER_ATTRIBUTES));
-    } catch (InvalidProtocolBufferException e) {
-      throw new IllegalStateException("Couldn't parse NumberAttributes bytes");
-    }
-
-    CoalescedRow.Builder coalescedRowBuilder =
-        CoalescedRow.newBuilder()
-            .setId(cursor.getLong(ID))
-            .setTimestamp(cursor.getLong(TIMESTAMP))
-            .setNumber(number)
-            .setNumberPresentation(cursor.getInt(NUMBER_PRESENTATION))
-            .setIsRead(cursor.getInt(IS_READ) == 1)
-            .setIsNew(cursor.getInt(NEW) == 1)
-            .setFeatures(cursor.getInt(FEATURES))
-            .setCallType(cursor.getInt(CALL_TYPE))
-            .setNumberAttributes(numberAttributes)
-            .setIsVoicemailCall(cursor.getInt(IS_VOICEMAIL_CALL) == 1)
-            .setCoalescedIds(coalescedIds);
-
-    String formattedNumber = cursor.getString(FORMATTED_NUMBER);
-    if (!TextUtils.isEmpty(formattedNumber)) {
-      coalescedRowBuilder.setFormattedNumber(formattedNumber);
-    }
-
-    String geocodedLocation = cursor.getString(GEOCODED_LOCATION);
-    if (!TextUtils.isEmpty(geocodedLocation)) {
-      coalescedRowBuilder.setGeocodedLocation(geocodedLocation);
-    }
-
-    String phoneAccountComponentName = cursor.getString(PHONE_ACCOUNT_COMPONENT_NAME);
-    if (!TextUtils.isEmpty(phoneAccountComponentName)) {
-      coalescedRowBuilder.setPhoneAccountComponentName(
-          cursor.getString(PHONE_ACCOUNT_COMPONENT_NAME));
-    }
-
-    String phoneAccountId = cursor.getString(PHONE_ACCOUNT_ID);
-    if (!TextUtils.isEmpty(phoneAccountId)) {
-      coalescedRowBuilder.setPhoneAccountId(phoneAccountId);
-    }
-
-    String voicemailCallTag = cursor.getString(VOICEMAIL_CALL_TAG);
-    if (!TextUtils.isEmpty(voicemailCallTag)) {
-      coalescedRowBuilder.setVoicemailCallTag(voicemailCallTag);
-    }
-
-    return coalescedRowBuilder.build();
-  }
-
-  static long getTimestamp(Cursor cursor) {
-    return cursor.getLong(TIMESTAMP);
-  }
-}
diff --git a/java/com/android/dialer/calllog/ui/NewCallLogAdapter.java b/java/com/android/dialer/calllog/ui/NewCallLogAdapter.java
index 69cc02b..501cf16 100644
--- a/java/com/android/dialer/calllog/ui/NewCallLogAdapter.java
+++ b/java/com/android/dialer/calllog/ui/NewCallLogAdapter.java
@@ -26,6 +26,7 @@
 import android.support.v7.widget.RecyclerView.ViewHolder;
 import android.view.LayoutInflater;
 import android.view.ViewGroup;
+import com.android.dialer.calllog.database.Coalescer;
 import com.android.dialer.calllogutils.CallLogDates;
 import com.android.dialer.common.Assert;
 import com.android.dialer.duo.Duo;
@@ -147,7 +148,7 @@
     int numItemsInToday = 0;
     int numItemsInYesterday = 0;
     do {
-      long timestamp = CoalescedAnnotatedCallLogCursorLoader.getTimestamp(cursor);
+      long timestamp = Coalescer.getTimestamp(cursor);
       long dayDifference = CallLogDates.getDayDifference(currentTimeMillis, timestamp);
       if (dayDifference == 0) {
         numItemsInToday++;
diff --git a/java/com/android/dialer/calllog/ui/NewCallLogFragment.java b/java/com/android/dialer/calllog/ui/NewCallLogFragment.java
index bc57507..1890b74 100644
--- a/java/com/android/dialer/calllog/ui/NewCallLogFragment.java
+++ b/java/com/android/dialer/calllog/ui/NewCallLogFragment.java
@@ -31,14 +31,18 @@
 import android.view.ViewGroup;
 import com.android.dialer.calllog.CallLogComponent;
 import com.android.dialer.calllog.RefreshAnnotatedCallLogReceiver;
+import com.android.dialer.calllog.database.CallLogDatabaseComponent;
 import com.android.dialer.common.Assert;
 import com.android.dialer.common.LogUtil;
 import com.android.dialer.common.concurrent.DefaultFutureCallback;
+import com.android.dialer.common.concurrent.DialerExecutorComponent;
+import com.android.dialer.common.concurrent.SupportUiListener;
 import com.android.dialer.common.concurrent.ThreadUtil;
 import com.android.dialer.metrics.Metrics;
 import com.android.dialer.metrics.MetricsComponent;
 import com.android.dialer.metrics.jank.RecyclerViewJankLogger;
 import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.MoreExecutors;
 import java.util.concurrent.TimeUnit;
 
@@ -48,8 +52,9 @@
   @VisibleForTesting
   static final long MARK_ALL_CALLS_READ_WAIT_MILLIS = TimeUnit.SECONDS.toMillis(3);
 
-  private RefreshAnnotatedCallLogReceiver refreshAnnotatedCallLogReceiver;
   private RecyclerView recyclerView;
+  private RefreshAnnotatedCallLogReceiver refreshAnnotatedCallLogReceiver;
+  private SupportUiListener<Cursor> coalesingAnnotatedCallLogListener;
 
   private boolean shouldMarkCallsRead = false;
   private final Runnable setShouldMarkCallsReadTrue = () -> shouldMarkCallsRead = true;
@@ -188,6 +193,11 @@
         new RecyclerViewJankLogger(
             MetricsComponent.get(getContext()).metrics(), Metrics.NEW_CALL_LOG_JANK_EVENT_NAME));
 
+    coalesingAnnotatedCallLogListener =
+        DialerExecutorComponent.get(getContext())
+            .createUiListener(
+                getChildFragmentManager(),
+                /* taskId = */ "NewCallLogFragment.coalescingAnnotatedCallLog");
     getLoaderManager().restartLoader(0, null, this);
 
     return view;
@@ -214,7 +224,7 @@
   @Override
   public Loader<Cursor> onCreateLoader(int id, Bundle args) {
     LogUtil.enterBlock("NewCallLogFragment.onCreateLoader");
-    return new CoalescedAnnotatedCallLogCursorLoader(getContext());
+    return new AnnotatedCallLogCursorLoader(Assert.isNotNull(getContext()));
   }
 
   @Override
@@ -228,17 +238,38 @@
       return;
     }
 
-    // TODO(zachh): Handle empty cursor by showing empty view.
-    if (recyclerView.getAdapter() == null) {
-      recyclerView.setLayoutManager(new LinearLayoutManager(getContext()));
-      // Note: It's not clear if this callback can be invoked when there's no associated activity,
-      // but if crashes are observed here it may be possible to use getContext() instead.
-      Activity activity = Assert.isNotNull(getActivity());
-      recyclerView.setAdapter(
-          new NewCallLogAdapter(activity, newCursor, System::currentTimeMillis));
-    } else {
-      ((NewCallLogAdapter) recyclerView.getAdapter()).updateCursor(newCursor);
-    }
+    // Start combining adjacent rows which should be collapsed for display purposes.
+    // This is a time-consuming process so we will do it in the background.
+    ListenableFuture<Cursor> coalescedCursorFuture =
+        CallLogDatabaseComponent.get(getContext()).coalescer().coalesce(newCursor);
+
+    coalesingAnnotatedCallLogListener.listen(
+        getContext(),
+        coalescedCursorFuture,
+        coalescedCursor -> {
+          LogUtil.i("NewCallLogFragment.onLoadFinished", "coalescing succeeded");
+
+          // TODO(zachh): Handle empty cursor by showing empty view.
+          if (recyclerView.getAdapter() == null) {
+            recyclerView.setLayoutManager(new LinearLayoutManager(getContext()));
+            // Note: It's not clear if this callback can be invoked when there's no associated
+            // activity, but if crashes are observed here it may be possible to use getContext()
+            // instead.
+            Activity activity = Assert.isNotNull(getActivity());
+            recyclerView.setAdapter(
+                new NewCallLogAdapter(activity, coalescedCursor, System::currentTimeMillis));
+          } else {
+            ((NewCallLogAdapter) recyclerView.getAdapter()).updateCursor(coalescedCursor);
+          }
+        },
+        throwable -> {
+          // Coalescing can fail if the cursor passed to Coalescer is closed by the loader while
+          // the work is still in progress.
+          // This can happen when the loader restarts and finishes loading data before the
+          // coalescing work is completed.
+          // TODO(linyuh): throw an exception here if the failure above can be avoided.
+          LogUtil.e("NewCallLogFragment.onLoadFinished", "coalescing failed", throwable);
+        });
   }
 
   @Override
diff --git a/java/com/android/dialer/calllog/ui/NewCallLogViewHolder.java b/java/com/android/dialer/calllog/ui/NewCallLogViewHolder.java
index 5f3cd96..fccd8b9 100644
--- a/java/com/android/dialer/calllog/ui/NewCallLogViewHolder.java
+++ b/java/com/android/dialer/calllog/ui/NewCallLogViewHolder.java
@@ -29,6 +29,7 @@
 import android.view.View;
 import android.widget.ImageView;
 import android.widget.TextView;
+import com.android.dialer.calllog.database.Coalescer;
 import com.android.dialer.calllog.model.CoalescedRow;
 import com.android.dialer.calllog.ui.NewCallLogAdapter.PopCounts;
 import com.android.dialer.calllog.ui.menu.NewCallLogMenu;
@@ -96,9 +97,12 @@
     uiExecutorService = DialerExecutorComponent.get(activity).uiExecutor();
   }
 
-  /** @param cursor a cursor from {@link CoalescedAnnotatedCallLogCursorLoader}. */
+  /**
+   * @param cursor a cursor for {@link
+   *     com.android.dialer.calllog.database.contract.AnnotatedCallLogContract.CoalescedAnnotatedCallLog}.
+   */
   void bind(Cursor cursor) {
-    CoalescedRow row = CoalescedAnnotatedCallLogCursorLoader.toRow(cursor);
+    CoalescedRow row = Coalescer.toRow(cursor);
     currentRowId = row.getId(); // Used to make sure async updates are applied to the correct views
 
     // Even if there is additional real time processing necessary, we still want to immediately show