[Journeys] De-dupe labels and show no. of matches in queryless view

Bug: 1303171
Change-Id: I9bfea490eea3d60caab9a1a0de1724695c4a81c3
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/3778777
Reviewed-by: Sophie Chang <[email protected]>
Commit-Queue: Patrick Noland <[email protected]>
Reviewed-by: Tommy Li <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1026991}
diff --git a/chrome/android/junit/src/org/chromium/chrome/browser/history_clusters/HistoryClustersCoordinatorTest.java b/chrome/android/junit/src/org/chromium/chrome/browser/history_clusters/HistoryClustersCoordinatorTest.java
index bd2746b2..38d7fbb 100644
--- a/chrome/android/junit/src/org/chromium/chrome/browser/history_clusters/HistoryClustersCoordinatorTest.java
+++ b/chrome/android/junit/src/org/chromium/chrome/browser/history_clusters/HistoryClustersCoordinatorTest.java
@@ -25,6 +25,7 @@
 import androidx.test.core.app.ActivityScenario;
 
 import com.google.android.material.tabs.TabLayout;
+import com.google.common.collect.ImmutableMap;
 
 import org.hamcrest.Matchers;
 import org.junit.After;
@@ -64,6 +65,7 @@
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashSet;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.concurrent.CopyOnWriteArraySet;
 
@@ -205,7 +207,8 @@
                 new ArrayList<>(), mGurl2, 123L, new ArrayList<>());
         mCluster = new HistoryCluster(Arrays.asList(mVisit1, mVisit2), "\"label\"", "label",
                 Collections.emptyList(), 123L, Arrays.asList("pugs", "terriers"));
-        mClusterResult = new HistoryClustersResult(Arrays.asList(mCluster), "dogs", false, false);
+        mClusterResult = new HistoryClustersResult(Arrays.asList(mCluster),
+                new LinkedHashMap<>(ImmutableMap.of("label", 1)), "dogs", false, false);
 
         mActivityScenario =
                 ActivityScenario.launch(ChromeTabbedActivity.class).onActivity(activity -> {
diff --git a/chrome/android/junit/src/org/chromium/chrome/browser/history_clusters/HistoryClustersMediatorTest.java b/chrome/android/junit/src/org/chromium/chrome/browser/history_clusters/HistoryClustersMediatorTest.java
index ba0ee34..2b971748 100644
--- a/chrome/android/junit/src/org/chromium/chrome/browser/history_clusters/HistoryClustersMediatorTest.java
+++ b/chrome/android/junit/src/org/chromium/chrome/browser/history_clusters/HistoryClustersMediatorTest.java
@@ -29,6 +29,8 @@
 import androidx.recyclerview.widget.LinearLayoutManager;
 import androidx.recyclerview.widget.RecyclerView;
 
+import com.google.common.collect.ImmutableMap;
+
 import org.hamcrest.BaseMatcher;
 import org.hamcrest.Description;
 import org.hamcrest.Matcher;
@@ -71,6 +73,7 @@
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.concurrent.TimeUnit;
 
@@ -248,12 +251,17 @@
                 new ArrayList<>(), 123L, Collections.emptyList());
         mCluster3 = new HistoryCluster(Arrays.asList(mVisit4), "\"label3\"", "label3",
                 new ArrayList<>(), 789L, Collections.EMPTY_LIST);
-        mHistoryClustersResultWithQuery = new HistoryClustersResult(
-                Arrays.asList(mCluster1, mCluster2), "query", true, false);
-        mHistoryClustersFollowupResultWithQuery =
-                new HistoryClustersResult(Arrays.asList(mCluster3), "query", false, true);
+        mHistoryClustersResultWithQuery =
+                new HistoryClustersResult(Arrays.asList(mCluster1, mCluster2),
+                        new LinkedHashMap<>(ImmutableMap.of("label", 1)), "query", true, false);
+        mHistoryClustersFollowupResultWithQuery = new HistoryClustersResult(
+                Arrays.asList(mCluster3),
+                new LinkedHashMap<>(ImmutableMap.of("label", 1, "hostname.com", 1, "label3", 1)),
+                "query", false, true);
         mHistoryClustersResultEmptyQuery =
-                new HistoryClustersResult(Arrays.asList(mCluster1, mCluster2), "", false, false);
+                new HistoryClustersResult(Arrays.asList(mCluster1, mCluster2),
+                        new LinkedHashMap<>(ImmutableMap.of("label", 1, "hostname.com", 1)), "",
+                        false, false);
     }
 
     @Test
@@ -318,8 +326,9 @@
 
         ListItem item = mModelList.get(3);
         PropertyModel model = item.model;
-        assertTrue(model.getAllSetProperties().containsAll(Arrays.asList(
-                HistoryClustersItemProperties.CLICK_HANDLER, HistoryClustersItemProperties.TITLE)));
+        assertTrue(model.getAllSetProperties().containsAll(
+                Arrays.asList(HistoryClustersItemProperties.CLICK_HANDLER,
+                        HistoryClustersItemProperties.TITLE, HistoryClustersItemProperties.LABEL)));
     }
 
     @Test
@@ -496,6 +505,7 @@
         doReturn(secondPromise).when(mBridge).loadMoreClusters("query");
         doReturn(3).when(mLayoutManager).findLastVisibleItemPosition();
 
+        mMediator.setQueryState(QueryState.forQuery("query", ""));
         mMediator.startQuery("query");
         fulfillPromise(promise, mHistoryClustersResultWithQuery);
 
diff --git a/chrome/browser/history_clusters/history_clusters_bridge.cc b/chrome/browser/history_clusters/history_clusters_bridge.cc
index 13bd5c3f..19ae258 100644
--- a/chrome/browser/history_clusters/history_clusters_bridge.cc
+++ b/chrome/browser/history_clusters/history_clusters_bridge.cc
@@ -177,11 +177,23 @@
   }
   ScopedJavaLocalRef<jclass> cluster_type = base::android::GetClass(
       env, "org/chromium/chrome/browser/history_clusters/HistoryCluster");
+  std::vector<std::u16string> unique_raw_labels;
+  std::vector<int> label_counts;
+  if (query_clusters_state_->query().empty()) {
+    for (const auto& label_entry :
+         query_clusters_state_->raw_label_counts_so_far()) {
+      unique_raw_labels.push_back(label_entry.first);
+      label_counts.push_back(label_entry.second);
+    }
+  }
+
   const ScopedJavaLocalRef<jobject>& j_result =
       Java_HistoryClustersBridge_buildClusterResult(
           env,
           base::android::ToTypedJavaArrayOfObjects(env, j_clusters,
                                                    cluster_type),
+          base::android::ToJavaArrayOfStrings(env, unique_raw_labels),
+          base::android::ToJavaIntArray(env, label_counts),
           base::android::ConvertUTF8ToJavaString(env, query), can_load_more,
           is_continuation);
   base::android::RunObjectCallbackAndroid(j_callback, j_result);
diff --git a/chrome/browser/history_clusters/java/src/org/chromium/chrome/browser/history_clusters/HistoryClustersBridge.java b/chrome/browser/history_clusters/java/src/org/chromium/chrome/browser/history_clusters/HistoryClustersBridge.java
index 6f84036..3e07fb6 100644
--- a/chrome/browser/history_clusters/java/src/org/chromium/chrome/browser/history_clusters/HistoryClustersBridge.java
+++ b/chrome/browser/history_clusters/java/src/org/chromium/chrome/browser/history_clusters/HistoryClustersBridge.java
@@ -17,6 +17,7 @@
 
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.LinkedHashMap;
 import java.util.List;
 
 @JNINamespace("history_clusters")
@@ -69,10 +70,18 @@
     }
 
     @CalledByNative
-    static HistoryClustersResult buildClusterResult(
-            HistoryCluster[] clusters, String query, boolean canLoadMore, boolean isContinuation) {
+    static HistoryClustersResult buildClusterResult(HistoryCluster[] clusters,
+            String[] uniqueRawLabels, int[] labelCounts, String query, boolean canLoadMore,
+            boolean isContinuation) {
+        assert uniqueRawLabels.length == labelCounts.length;
+        LinkedHashMap<String, Integer> labelCountsMap = new LinkedHashMap<>();
+        for (int i = 0; i < uniqueRawLabels.length; i++) {
+            labelCountsMap.put(uniqueRawLabels[i], labelCounts[i]);
+        }
+
         List<HistoryCluster> clustersList = Arrays.asList(clusters);
-        return new HistoryClustersResult(clustersList, query, canLoadMore, isContinuation);
+        return new HistoryClustersResult(
+                clustersList, labelCountsMap, query, canLoadMore, isContinuation);
     }
 
     @CalledByNative
diff --git a/chrome/browser/history_clusters/java/src/org/chromium/chrome/browser/history_clusters/HistoryClustersMediator.java b/chrome/browser/history_clusters/java/src/org/chromium/chrome/browser/history_clusters/HistoryClustersMediator.java
index bb13fbf..e3a202f0 100644
--- a/chrome/browser/history_clusters/java/src/org/chromium/chrome/browser/history_clusters/HistoryClustersMediator.java
+++ b/chrome/browser/history_clusters/java/src/org/chromium/chrome/browser/history_clusters/HistoryClustersMediator.java
@@ -47,7 +47,9 @@
 
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.concurrent.TimeUnit;
 
 class HistoryClustersMediator extends RecyclerView.OnScrollListener implements SearchDelegate {
@@ -79,6 +81,7 @@
     private ListItem mClearBrowsingDataItem;
     private QueryState mQueryState = QueryState.forQueryless();
     private final HistoryClustersMetricsLogger mMetricsLogger;
+    private Map<String, PropertyModel> mLabelToModelMap = new LinkedHashMap<>();
 
     /**
      * Create a new HistoryClustersMediator.
@@ -134,7 +137,7 @@
     // SearchDelegate implementation.
     @Override
     public void onSearchTextChanged(String query) {
-        mModelList.clear();
+        resetModel();
         startQuery(query);
     }
 
@@ -166,7 +169,7 @@
         mQueryState = queryState;
         mToolbarModel.set(HistoryClustersToolbarProperties.QUERY_STATE, queryState);
         if (!queryState.isSearching()) {
-            mModelList.clear();
+            resetModel();
             startQuery(mQueryState.getQuery());
         }
     }
@@ -267,7 +270,7 @@
         }
         mDelegate.removeMarkedItems();
 
-        mModelList.clear();
+        resetModel();
         startQuery(mQueryState.getQuery());
     }
 
@@ -282,6 +285,39 @@
         boolean isQueryLess = !mQueryState.isSearching();
         if (isQueryLess) {
             ensureHeaders();
+            for (Map.Entry<String, Integer> entry : result.getLabelCounts().entrySet()) {
+                // Check if label exists in the model already
+                // If not, create a new entry
+                String rawLabel = entry.getKey();
+                PropertyModel existingModel = mLabelToModelMap.get(rawLabel);
+                if (existingModel == null) {
+                    existingModel = new PropertyModel(HistoryClustersItemProperties.ALL_KEYS);
+                    mLabelToModelMap.put(rawLabel, existingModel);
+                    Drawable journeysDrawable =
+                            AppCompatResources.getDrawable(mContext, R.drawable.ic_journeys);
+                    existingModel.set(
+                            HistoryClustersItemProperties.ICON_DRAWABLE, journeysDrawable);
+                    existingModel.set(HistoryClustersItemProperties.DIVIDER_VISIBLE, true);
+                    existingModel.set(HistoryClustersItemProperties.TITLE,
+                            getQuotedLabelFromRawLabel(rawLabel, result.getClusters()));
+                    ListItem clusterItem = new ListItem(ItemType.CLUSTER, existingModel);
+                    mModelList.add(clusterItem);
+                    existingModel.set(HistoryClustersItemProperties.CLICK_HANDLER,
+                            (v)
+                                    -> setQueryState(QueryState.forQuery(
+                                            rawLabel, mDelegate.getSearchEmptyString())));
+                    existingModel.set(HistoryClustersItemProperties.END_BUTTON_DRAWABLE, null);
+                }
+                existingModel.set(HistoryClustersItemProperties.LABEL,
+                        mResources.getQuantityString(R.plurals.history_clusters_n_matches,
+                                entry.getValue(), entry.getValue()));
+            }
+
+            if (result.canLoadMore() && !result.isContinuation()) {
+                continueQuery("");
+            }
+
+            return;
         }
 
         for (HistoryCluster cluster : result.getClusters()) {
@@ -294,15 +330,6 @@
             clusterModel.set(HistoryClustersItemProperties.DIVIDER_VISIBLE, isQueryLess);
             ListItem clusterItem = new ListItem(ItemType.CLUSTER, clusterModel);
             mModelList.add(clusterItem);
-            if (isQueryLess) {
-                clusterModel.set(HistoryClustersItemProperties.CLICK_HANDLER,
-                        (v)
-                                -> setQueryState(QueryState.forQuery(
-                                        cluster.getRawLabel(), mDelegate.getSearchEmptyString())));
-                clusterModel.set(HistoryClustersItemProperties.END_BUTTON_DRAWABLE, null);
-                clusterModel.set(HistoryClustersItemProperties.LABEL, null);
-                continue;
-            }
 
             List<ListItem> visitsAndRelatedSearches =
                     new ArrayList<>(cluster.getVisits().size() + 1);
@@ -365,6 +392,22 @@
         }
     }
 
+    private void resetModel() {
+        mModelList.clear();
+        mLabelToModelMap.clear();
+    }
+
+    private String getQuotedLabelFromRawLabel(String rawLabel, List<HistoryCluster> clusters) {
+        for (HistoryCluster cluster : clusters) {
+            if (cluster.getRawLabel().equals(rawLabel)) {
+                return cluster.getLabel();
+            }
+        }
+
+        // This shouldn't happen, but the unquoted label is a graceful fallback in case it does.
+        return rawLabel;
+    }
+
     private void ensureHeaders() {
         if (mQueryState.isSearching()) {
             return;
diff --git a/chrome/browser/history_clusters/java/src/org/chromium/chrome/browser/history_clusters/HistoryClustersResult.java b/chrome/browser/history_clusters/java/src/org/chromium/chrome/browser/history_clusters/HistoryClustersResult.java
index 06daa27..4a76c13 100644
--- a/chrome/browser/history_clusters/java/src/org/chromium/chrome/browser/history_clusters/HistoryClustersResult.java
+++ b/chrome/browser/history_clusters/java/src/org/chromium/chrome/browser/history_clusters/HistoryClustersResult.java
@@ -7,7 +7,9 @@
 import androidx.annotation.VisibleForTesting;
 
 import java.util.Collections;
+import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Map;
 
 @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
 /**
@@ -16,18 +18,25 @@
  */
 public class HistoryClustersResult {
     private final List<HistoryCluster> mClusters;
+    private final LinkedHashMap<String, Integer> mLabelCounts;
     private final String mQuery;
     private final boolean mCanLoadMore;
     private final boolean mIsContinuation;
 
     /** Create a new result with no clusters and an empty query. */
     public static HistoryClustersResult emptyResult() {
-        return new HistoryClustersResult(Collections.EMPTY_LIST, "", false, false);
+        return new HistoryClustersResult(
+                Collections.EMPTY_LIST, new LinkedHashMap<>(), "", false, false);
     }
 
-    HistoryClustersResult(List<HistoryCluster> clusters, String query, boolean canLoadMore,
-            boolean isContinuation) {
+    /**
+     * Constructs a new HistoryClustersResult. {@code labelCounts} must be a LinkedHashMap so that
+     * order is stable and preserved.
+     */
+    HistoryClustersResult(List<HistoryCluster> clusters, LinkedHashMap<String, Integer> labelCounts,
+            String query, boolean canLoadMore, boolean isContinuation) {
         mClusters = clusters;
+        mLabelCounts = labelCounts;
         mQuery = query;
         mCanLoadMore = canLoadMore;
         mIsContinuation = isContinuation;
@@ -44,4 +53,12 @@
     public boolean canLoadMore() {
         return mCanLoadMore;
     }
+
+    public Map<String, Integer> getLabelCounts() {
+        return mLabelCounts;
+    }
+
+    public boolean isContinuation() {
+        return mIsContinuation;
+    }
 }
diff --git a/components/history_clusters/core/query_clusters_state.cc b/components/history_clusters/core/query_clusters_state.cc
index cc4a3ed..3bc58d9 100644
--- a/components/history_clusters/core/query_clusters_state.cc
+++ b/components/history_clusters/core/query_clusters_state.cc
@@ -166,14 +166,19 @@
       return;
 
     const auto& raw_label_value = cluster.raw_label.value();
-
-    // Subtle code below: If we've NEVER encountered the label before, this []
-    // operator initializes the count to 0, and then post-increments it to 1.
-    // If it already exists, the post-increment will up the count, but will
-    // return false.
-    if (raw_label_counts_[raw_label_value]++ == 0) {
-      unique_raw_labels_.push_back(raw_label_value);
+    // Warning: N^2 algorithm below. If this ends up scaling poorly, it can be
+    // optimized by adding a map that tracks which labels have been seen
+    // already.
+    auto it = std::find_if(raw_label_counts_so_far_.begin(),
+                           raw_label_counts_so_far_.end(),
+                           [&raw_label_value](const LabelCount& label_count) {
+                             return label_count.first == raw_label_value;
+                           });
+    if (it == raw_label_counts_so_far_.end()) {
+      it = raw_label_counts_so_far_.insert(it,
+                                           std::make_pair(raw_label_value, 0));
     }
+    it->second++;
   }
 }
 
diff --git a/components/history_clusters/core/query_clusters_state.h b/components/history_clusters/core/query_clusters_state.h
index 7d5c5d6..511c512 100644
--- a/components/history_clusters/core/query_clusters_state.h
+++ b/components/history_clusters/core/query_clusters_state.h
@@ -24,6 +24,8 @@
 
 class HistoryClustersService;
 
+using LabelCount = std::pair<std::u16string, size_t>;
+
 // This object encapsulates the results of a query to HistoryClustersService.
 // It manages fetching more pages from the clustering backend as the user
 // scrolls down.
@@ -54,15 +56,12 @@
   // Used to request another batch of clusters of the same query.
   void LoadNextBatchOfClusters(ResultCallback callback);
 
-  // Use these together to iterate through the list of raw labels in the same
-  // order as the clusters are ordered. The counts can be fetched by inputting
-  // the labels into the map as keys - but note, this only counts the number
-  // of label instances seen SO FAR, not necessarily in all of History.
-  const std::vector<std::u16string>& unique_raw_labels() {
-    return unique_raw_labels_;
-  }
-  const std::map<std::u16string, size_t>& raw_label_counts() {
-    return raw_label_counts_;
+  // The list of raw labels in the same order as the clusters are ordered
+  // alongside the number of occurrences so far. The counts can be fetched by
+  // inputting the labels into the map as keys - but note, this only counts the
+  // number of label instances seen SO FAR, not necessarily in all of History.
+  const std::vector<LabelCount>& raw_label_counts_so_far() {
+    return raw_label_counts_so_far_;
   }
 
  private:
@@ -97,16 +96,11 @@
   // The string query the user entered into the searchbox.
   const std::string query_;
 
-  // The de-duplicated list of raw labels we've seen so far, in the same order
-  // as the clusters themselves were provided. This is only computed if `query`
-  // is empty. For non-empty `query`, this will be an empty list.
-  std::vector<std::u16string> unique_raw_labels_;
-  // Counts the number of instances of each raw label we've seen. Note that
-  // the value will always be an integer 1 or above. This is also used for our
-  // internal uniqueness test. Note: this only counts the number of raw labels
-  // of this string seen SO FAR, so unless we iterate through ALL the clusters,
-  // this number may be smaller than the total number in History.
-  std::map<std::u16string, size_t> raw_label_counts_;
+  // The de-duplicated list of raw labels we've seen so far and their number of
+  // occurrences, in the same order as the clusters themselves were provided.
+  // This is only computed if `query` is empty. For non-empty `query`, this will
+  // be an empty list.
+  std::vector<LabelCount> raw_label_counts_so_far_;
 
   // The continuation params used to track where the last query left off and
   // query for the "next page".
diff --git a/components/history_clusters/core/query_clusters_state_unittest.cc b/components/history_clusters/core/query_clusters_state_unittest.cc
index 0941a51c..3b5d1ce 100644
--- a/components/history_clusters/core/query_clusters_state_unittest.cc
+++ b/components/history_clusters/core/query_clusters_state_unittest.cc
@@ -285,20 +285,18 @@
   auto result = InjectRawClustersAndAwaitPostProcessing(
       &state, {cluster1, cluster2, cluster4}, {});
   ASSERT_EQ(result.cluster_batch.size(), 3U);
-  EXPECT_THAT(state.unique_raw_labels(),
-              ElementsAre(u"rawlabel1", u"rawlabel2"));
-  EXPECT_EQ(state.raw_label_counts().at(u"rawlabel1"), 2U);
-  EXPECT_EQ(state.raw_label_counts().at(u"rawlabel2"), 1U);
+  EXPECT_THAT(state.raw_label_counts_so_far(),
+              ElementsAre(std::make_pair(u"rawlabel1", 2),
+                          std::make_pair(u"rawlabel2", 1)));
 
   // Test updating an existing count, and adding new ones after that.
   result =
       InjectRawClustersAndAwaitPostProcessing(&state, {cluster5, cluster3}, {});
   ASSERT_EQ(result.cluster_batch.size(), 2U);
-  EXPECT_THAT(state.unique_raw_labels(),
-              ElementsAre(u"rawlabel1", u"rawlabel2", u"rawlabel3"));
-  EXPECT_EQ(state.raw_label_counts().at(u"rawlabel1"), 2U);
-  EXPECT_EQ(state.raw_label_counts().at(u"rawlabel2"), 2U);
-  EXPECT_EQ(state.raw_label_counts().at(u"rawlabel3"), 1U);
+  EXPECT_THAT(state.raw_label_counts_so_far(),
+              ElementsAre(std::make_pair(u"rawlabel1", 2),
+                          std::make_pair(u"rawlabel2", 2),
+                          std::make_pair(u"rawlabel3", 1)));
 }
 
 }  // namespace history_clusters
diff --git a/components/history_clusters_strings.grdp b/components/history_clusters_strings.grdp
index 30670a4..fe94d0a 100644
--- a/components/history_clusters_strings.grdp
+++ b/components/history_clusters_strings.grdp
@@ -70,4 +70,8 @@
   <message name="IDS_HISTORY_CLUSTERS_SEARCH_YOUR_JOURNEYS" desc="A label for the hint text for the search box in Chrome Journeys." formatter_data="android_java">
     Search your Journeys
   </message>
+  <message name="IDS_HISTORY_CLUSTERS_N_MATCHES" desc="A label for the number of matching clusters for a given label." formatter_data="android_java">
+   {NUM_MATCHES, plural,
+        =1 {# match} other {# matches}}
+  </message>
 </grit-part>
diff --git a/components/history_clusters_strings_grdp/IDS_HISTORY_CLUSTERS_N_MATCHES.png.sha1 b/components/history_clusters_strings_grdp/IDS_HISTORY_CLUSTERS_N_MATCHES.png.sha1
new file mode 100644
index 0000000..160e9ad
--- /dev/null
+++ b/components/history_clusters_strings_grdp/IDS_HISTORY_CLUSTERS_N_MATCHES.png.sha1
@@ -0,0 +1 @@
+e5b8707aa16ed376ba83f2258f78b489b928d069
\ No newline at end of file