Add contact search display name experiment (1/2)

We make two queries which check if the display
name exactly matches the phonetic name in order
to prevent duplicates when the display name is
coming from the phonetic name.

Bug 26697731

Change-Id: Iab2e8b158bcc6f6df06c23da257d9d930363e60a
(cherry picked from commit 142ad048c17284aa14e9f88a31074fe2ee5a6f45)
diff --git a/src/com/android/contacts/common/Experiments.java b/src/com/android/contacts/common/Experiments.java
index be115f7..c33752b 100644
--- a/src/com/android/contacts/common/Experiments.java
+++ b/src/com/android/contacts/common/Experiments.java
@@ -16,11 +16,18 @@
 package com.android.contacts.common;
 
 /**
- * Experiment flag name constants.
+ * Experiment flag names.
  */
 public final class Experiments {
 
     /**
+     * Search study boolean indicating whether to perform a simple display name query, instead of
+     * the normal type-to-filter query.
+     */
+    public static final String FLAG_SEARCH_DISPLAY_NAME_QUERY =
+            "Search__display_name_query";
+
+    /**
      * Search study boolean indicating whether to order starred and frequently occurring
      * search results first.
      */
diff --git a/src/com/android/contacts/common/list/ContactListAdapter.java b/src/com/android/contacts/common/list/ContactListAdapter.java
index a2fb651..bc8bdb9 100644
--- a/src/com/android/contacts/common/list/ContactListAdapter.java
+++ b/src/com/android/contacts/common/list/ContactListAdapter.java
@@ -48,6 +48,7 @@
             Contacts.PHOTO_THUMBNAIL_URI,           // 5
             Contacts.LOOKUP_KEY,                    // 6
             Contacts.IS_USER_PROFILE,               // 7
+            Contacts.PHONETIC_NAME,                 // 8
         };
 
         private static final String[] CONTACT_PROJECTION_ALTERNATIVE = new String[] {
@@ -59,6 +60,7 @@
             Contacts.PHOTO_THUMBNAIL_URI,           // 5
             Contacts.LOOKUP_KEY,                    // 6
             Contacts.IS_USER_PROFILE,               // 7
+            Contacts.PHONETIC_NAME,                 // 8
         };
 
         private static final String[] FILTER_PROJECTION_PRIMARY = new String[] {
@@ -70,9 +72,10 @@
             Contacts.PHOTO_THUMBNAIL_URI,           // 5
             Contacts.LOOKUP_KEY,                    // 6
             Contacts.IS_USER_PROFILE,               // 7
-            Contacts.TIMES_CONTACTED,               // 8
-            Contacts.STARRED,                       // 9
-            SearchSnippets.SNIPPET,                 // 10
+            Contacts.PHONETIC_NAME,                 // 8
+            Contacts.TIMES_CONTACTED,               // 9
+            Contacts.STARRED,                       // 10
+            SearchSnippets.SNIPPET,                 // 11
         };
 
         private static final String[] FILTER_PROJECTION_ALTERNATIVE = new String[] {
@@ -84,9 +87,10 @@
             Contacts.PHOTO_THUMBNAIL_URI,           // 5
             Contacts.LOOKUP_KEY,                    // 6
             Contacts.IS_USER_PROFILE,               // 7
-            Contacts.TIMES_CONTACTED,               // 8
-            Contacts.STARRED,                       // 9
-            SearchSnippets.SNIPPET,                 // 10
+            Contacts.PHONETIC_NAME,                 // 8
+            Contacts.TIMES_CONTACTED,               // 9
+            Contacts.STARRED,                       // 10
+            SearchSnippets.SNIPPET,                 // 11
         };
 
         public static final int CONTACT_ID               = 0;
@@ -97,12 +101,15 @@
         public static final int CONTACT_PHOTO_URI        = 5;
         public static final int CONTACT_LOOKUP_KEY       = 6;
         public static final int CONTACT_IS_USER_PROFILE  = 7;
-        public static final int CONTACT_TIMES_CONTACTED  = 8;
-        public static final int CONTACT_STARRED          = 9;
-        public static final int CONTACT_SNIPPET          = 10;
+        public static final int CONTACT_PHONETIC_NAME    = 8;
+        public static final int CONTACT_TIMES_CONTACTED  = 9;
+        public static final int CONTACT_STARRED          = 10;
+        public static final int CONTACT_SNIPPET          = 11;
     }
 
-    protected static class StrequentQuery {
+    // NOTE: These projections must match those in ContactQuery above expect we omit the
+    // SearchSnippers.SNIPPET column since that is only supported with Contacts.CONTENT_FILTER_URI.
+    protected static class ExperimentQuery {
 
         private static final String[] FILTER_PROJECTION_PRIMARY = new String[] {
                 Contacts._ID,                           // 0
@@ -113,8 +120,9 @@
                 Contacts.PHOTO_THUMBNAIL_URI,           // 5
                 Contacts.LOOKUP_KEY,                    // 6
                 Contacts.IS_USER_PROFILE,               // 7
-                Contacts.TIMES_CONTACTED,               // 8
-                Contacts.STARRED,                       // 9
+                Contacts.PHONETIC_NAME,                 // 8
+                Contacts.TIMES_CONTACTED,               // 9
+                Contacts.STARRED,                       // 10
                 // SearchSnippets.SNIPPET not supported
         };
 
@@ -127,8 +135,9 @@
                 Contacts.PHOTO_THUMBNAIL_URI,           // 5
                 Contacts.LOOKUP_KEY,                    // 6
                 Contacts.IS_USER_PROFILE,               // 7
-                Contacts.TIMES_CONTACTED,               // 8
-                Contacts.STARRED,                       // 9
+                Contacts.PHONETIC_NAME,                 // 8
+                Contacts.TIMES_CONTACTED,               // 9
+                Contacts.STARRED,                       // 10
                 // SearchSnippets.SNIPPET not supported
         };
 
@@ -140,8 +149,9 @@
         public static final int CONTACT_PHOTO_URI        = 5;
         public static final int CONTACT_LOOKUP_KEY       = 6;
         public static final int CONTACT_IS_USER_PROFILE  = 7;
-        public static final int CONTACT_TIMES_CONTACTED  = 8;
-        public static final int CONTACT_STARRED          = 9;
+        public static final int CONTACT_PHONETIC_NAME    = 8;
+        public static final int CONTACT_TIMES_CONTACTED  = 9;
+        public static final int CONTACT_STARRED          = 10;
         // SearchSnippets.SNIPPET not supported
     }
 
@@ -432,10 +442,13 @@
         }
     }
 
-    protected final String[] getStrequentProjection() {
+    /**
+     * Returns the projection useful for search experiments.
+     */
+    protected final String[] getExperimentProjection() {
         final int sortOrder = getContactNameDisplayOrder();
         return sortOrder == ContactsPreferences.DISPLAY_ORDER_PRIMARY
-                ? StrequentQuery.FILTER_PROJECTION_PRIMARY
-                : StrequentQuery.FILTER_PROJECTION_ALTERNATIVE;
+                ? ExperimentQuery.FILTER_PROJECTION_PRIMARY
+                : ExperimentQuery.FILTER_PROJECTION_ALTERNATIVE;
     }
 }
diff --git a/src/com/android/contacts/common/list/DefaultContactListAdapter.java b/src/com/android/contacts/common/list/DefaultContactListAdapter.java
index a1eac84..dcda062 100644
--- a/src/com/android/contacts/common/list/DefaultContactListAdapter.java
+++ b/src/com/android/contacts/common/list/DefaultContactListAdapter.java
@@ -27,6 +27,7 @@
 import android.provider.ContactsContract.Contacts;
 import android.provider.ContactsContract.Directory;
 import android.provider.ContactsContract.SearchSnippets;
+import android.support.annotation.VisibleForTesting;
 import android.text.TextUtils;
 import android.view.View;
 
@@ -56,12 +57,11 @@
             ((ProfileAndContactsLoader) loader).setLoadProfile(shouldIncludeProfile());
         }
 
-        ContactListFilter filter = getFilter();
+        String sortOrder = null;
         if (isSearchMode()) {
+            final Flags flags = Flags.getInstance(mContext);
             String query = getQueryString();
-            if (query == null) {
-                query = "";
-            }
+            if (query == null) query = "";
             query = query.trim();
             if (TextUtils.isEmpty(query)) {
                 // Regardless of the directory, we don't want anything returned,
@@ -69,55 +69,120 @@
                 loader.setUri(Contacts.CONTENT_URI);
                 loader.setProjection(getProjection(false));
                 loader.setSelection("0");
+            } else if (flags.getBoolean(Experiments.FLAG_SEARCH_DISPLAY_NAME_QUERY, false)) {
+                final String displayNameColumn =
+                        getContactNameDisplayOrder() == ContactsPreferences.DISPLAY_ORDER_PRIMARY
+                                ? Contacts.DISPLAY_NAME_PRIMARY : Contacts.DISPLAY_NAME_ALTERNATIVE;
+
+                final Builder builder = Contacts.CONTENT_URI.buildUpon();
+                appendSearchDirectoryParameters(builder, directoryId);
+                loader.setUri(builder.build());
+                loader.setProjection(getExperimentProjection());
+                loader.setSelection(getDisplayNameSelection(query, displayNameColumn));
+                loader.setSelectionArgs(getDisplayNameSelectionArgs(query));
+                if (flags.getBoolean(Experiments.FLAG_SEARCH_STREQUENTS_FIRST, false)) {
+                    sortOrder = String.format("%s DESC, %s DESC",
+                            Contacts.TIMES_CONTACTED, Contacts.STARRED);
+                }
             } else {
                 final Builder builder = ContactsCompat.getContentUri().buildUpon();
                 appendSearchParameters(builder, query, directoryId);
                 loader.setUri(builder.build());
                 loader.setProjection(getProjection(true));
-                if (Flags.getInstance(mContext).getBoolean(
-                        Experiments.FLAG_SEARCH_STREQUENTS_FIRST, false)) {
+                if (flags.getBoolean(Experiments.FLAG_SEARCH_STREQUENTS_FIRST, false)) {
                     // Filter out starred and frequently contacted contacts from the main loader
                     // query results
                     loader.setSelection(Contacts.TIMES_CONTACTED + "=0 AND "
                             + Contacts.STARRED + "=0");
 
-                    // Strequent contacts will be merged back in before the main loader query
-                    // results and after the profile (ME).
+                    // Strequent contacts will be merged back in after the profile (ME) and before
+                    // the main loader query results.
                     final ProfileAndContactsLoader profileAndContactsLoader =
                             (ProfileAndContactsLoader) loader;
-                    profileAndContactsLoader.setLoadStrequent(true);
                     final Builder strequentBuilder =
                             Contacts.CONTENT_STREQUENT_FILTER_URI.buildUpon();
                     appendSearchParameters(strequentBuilder, query, directoryId);
-                    profileAndContactsLoader.setStrequentUri(strequentBuilder.build());
-                    profileAndContactsLoader.setStrequentProjection(getStrequentProjection());
+                    profileAndContactsLoader.setLoadStrequents(
+                            strequentBuilder.build(), getExperimentProjection());
                 }
             }
         } else {
+            final ContactListFilter filter = getFilter();
             configureUri(loader, directoryId, filter);
             loader.setProjection(getProjection(false));
             configureSelection(loader, directoryId, filter);
         }
 
-        String sortOrder;
         if (getSortOrder() == ContactsPreferences.SORT_ORDER_PRIMARY) {
-            sortOrder = Contacts.SORT_KEY_PRIMARY;
+            if (sortOrder == null) {
+                sortOrder = Contacts.SORT_KEY_PRIMARY;
+            } else {
+                sortOrder += ", " + Contacts.SORT_KEY_PRIMARY;
+            }
         } else {
-            sortOrder = Contacts.SORT_KEY_ALTERNATIVE;
+            if (sortOrder == null) {
+                sortOrder = Contacts.SORT_KEY_ALTERNATIVE;
+            } else {
+                sortOrder += ", " + Contacts.SORT_KEY_ALTERNATIVE;
+            }
         }
-
         loader.setSortOrder(sortOrder);
     }
 
+    /**
+     * Splits the given query by whitespace and adds a display name and phonetic name selection
+     * clause once for each token.
+     *
+     * @param displayNameColumn The display name column to use in the returned selection String
+     */
+    @VisibleForTesting
+    static String getDisplayNameSelection(String query, String displayNameColumn) {
+        final String[] tokens = getDisplayNameSearchSelectionTokens(query);
+        if (tokens == null) return null;
+        final StringBuilder builder = new StringBuilder();
+        for (int i = 0; i < tokens.length; i++) {
+            if (builder.length() > 0) builder.append(" OR ");
+            final String param = "?" + (i + 1);
+            builder.append("(" + displayNameColumn + " LIKE " + param +
+                    " OR " + Contacts.PHONETIC_NAME + " LIKE " + param + ")");
+        }
+        return builder.toString();
+    }
+
+    /**
+     * Splits the given query by whitespace and returns the resulting tokens, each one
+     * wrapped with "%" on either side.
+     */
+    @VisibleForTesting
+    static String[] getDisplayNameSelectionArgs(String query) {
+        final String[] tokens = getDisplayNameSearchSelectionTokens(query);
+        if (tokens == null) return null;
+        for (int i = 0; i < tokens.length; i++) {
+            tokens[i] = "%" + tokens[i] + "%";
+        }
+        return tokens;
+    }
+
+    private static String[] getDisplayNameSearchSelectionTokens(String query) {
+        if (query == null) return null;
+        query = query.trim();
+        if (query.length() == 0) return null;
+        return query.split("\\s+");
+    }
+
     private void appendSearchParameters(Builder builder, String query, long directoryId) {
         builder.appendPath(query); // Builder will encode the query
+        appendSearchDirectoryParameters(builder, directoryId);
+        builder.appendQueryParameter(SearchSnippets.DEFERRED_SNIPPETING_KEY, "1");
+    }
+
+    private void appendSearchDirectoryParameters(Builder builder, long directoryId) {
         builder.appendQueryParameter(ContactsContract.DIRECTORY_PARAM_KEY,
                 String.valueOf(directoryId));
         if (directoryId != Directory.DEFAULT && directoryId != Directory.LOCAL_INVISIBLE) {
             builder.appendQueryParameter(ContactsContract.LIMIT_PARAM_KEY,
                     String.valueOf(getDirectoryResultLimit(getDirectoryById(directoryId))));
         }
-        builder.appendQueryParameter(SearchSnippets.DEFERRED_SNIPPETING_KEY, "1");
     }
 
     protected void configureUri(CursorLoader loader, long directoryId, ContactListFilter filter) {
diff --git a/src/com/android/contacts/common/list/ProfileAndContactsLoader.java b/src/com/android/contacts/common/list/ProfileAndContactsLoader.java
index e68d4a1..da5319e 100644
--- a/src/com/android/contacts/common/list/ProfileAndContactsLoader.java
+++ b/src/com/android/contacts/common/list/ProfileAndContactsLoader.java
@@ -22,7 +22,6 @@
 import android.database.MergeCursor;
 import android.net.Uri;
 import android.os.Bundle;
-import android.provider.ContactsContract.Contacts;
 import android.provider.ContactsContract.Profile;
 
 import com.google.common.collect.Lists;
@@ -36,10 +35,11 @@
 public class ProfileAndContactsLoader extends CursorLoader {
 
     private boolean mLoadProfile;
-    private boolean mLoadStrequent;
+
     private String[] mProjection;
-    private String[] mStrequentProjection;
+
     private Uri mStrequentUri;
+    private String[] mStrequentProjection;
 
     public ProfileAndContactsLoader(Context context) {
         super(context);
@@ -49,21 +49,14 @@
         mLoadProfile = flag;
     }
 
-    public void setLoadStrequent(boolean flag) {
-        mLoadStrequent = flag;
-    }
-
     public void setProjection(String[] projection) {
         super.setProjection(projection);
         mProjection = projection;
     }
 
-    public void setStrequentProjection(String[] projection) {
-        mStrequentProjection = projection;
-    }
-
-    public void setStrequentUri(Uri uri) {
+    public void setLoadStrequents(Uri uri, String[] projection) {
         mStrequentUri = uri;
+        mStrequentProjection = projection;
     }
 
     @Override
@@ -73,7 +66,7 @@
         if (mLoadProfile) {
             cursors.add(loadProfile());
         }
-        if (mLoadStrequent) {
+        if (mStrequentUri != null && mStrequentProjection != null) {
             cursors.add(loadStrequent());
         }
         // ContactsCursor.loadInBackground() can return null; MergeCursor
diff --git a/src/com/android/contacts/commonbind/experiments/Flags.java b/src/com/android/contacts/commonbind/experiments/Flags.java
index b0264fd..fdfe9d8 100644
--- a/src/com/android/contacts/commonbind/experiments/Flags.java
+++ b/src/com/android/contacts/commonbind/experiments/Flags.java
@@ -36,7 +36,7 @@
     }
 
     public boolean getBoolean(String flagName, boolean defValue) {
-        return false;
+        return defValue;
     }
 
     public float getFloat(String flagName, float defValue) {
diff --git a/tests/src/com/android/contacts/common/list/DefaultContactListAdapterTest.java b/tests/src/com/android/contacts/common/list/DefaultContactListAdapterTest.java
new file mode 100644
index 0000000..2112daa
--- /dev/null
+++ b/tests/src/com/android/contacts/common/list/DefaultContactListAdapterTest.java
@@ -0,0 +1,76 @@
+/*
+ * Copyright (C) 2016 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.contacts.common.list;
+
+import android.provider.ContactsContract.Contacts;
+import android.test.AndroidTestCase;
+import android.test.suitebuilder.annotation.SmallTest;
+
+import java.util.Arrays;
+
+import static com.android.contacts.common.list.DefaultContactListAdapter.getDisplayNameSelection;
+import static com.android.contacts.common.list.DefaultContactListAdapter.getDisplayNameSelectionArgs;
+
+/**
+ * Unit tests for {@link com.android.contacts.common.list.DefaultContactListAdapter}.
+ */
+@SmallTest
+public class DefaultContactListAdapterTest extends AndroidTestCase {
+
+    public void testDisplayNameSelection() {
+        final String dn = Contacts.DISPLAY_NAME;
+        assertNull(getDisplayNameSelection(null, dn));
+        assertNull(getDisplayNameSelection("", dn));
+        assertNull(getDisplayNameSelection(" ", dn));
+        assertNull(getDisplayNameSelection("\t", dn));
+        assertNull(getDisplayNameSelection("\t ", dn));
+
+        final String pn = Contacts.PHONETIC_NAME;
+        String expected = "(" + dn + " LIKE ?1 OR " + pn + " LIKE ?1)";
+        assertEquals(expected, getDisplayNameSelection("foo", dn));
+
+        expected = "(" + dn + " LIKE ?1 OR " + pn + " LIKE ?1) OR " +
+                "(" + dn + " LIKE ?2 OR " + pn + " LIKE ?2)";
+        assertEquals(expected, getDisplayNameSelection("foo bar", dn));
+        assertEquals(expected, getDisplayNameSelection(" foo bar ", dn));
+        assertEquals(expected, getDisplayNameSelection("foo\t bar", dn));
+        assertEquals(expected, getDisplayNameSelection(" \tfoo\t bar\t ", dn));
+    }
+
+    public void testDisplayNameSelectionArgs() {
+        assertNull(getDisplayNameSelectionArgs(null));
+        assertNull(getDisplayNameSelectionArgs(""));
+        assertNull(getDisplayNameSelectionArgs(" "));
+        assertNull(getDisplayNameSelectionArgs("\t"));
+        assertNull(getDisplayNameSelectionArgs("\t "));
+
+        String[] expected = new String[]{"%foo%"};
+        assertArrayEquals(expected, getDisplayNameSelectionArgs("foo"));
+
+        expected = new String[]{"%foo%","%bar%"};
+        assertArrayEquals(expected, getDisplayNameSelectionArgs("foo bar"));
+        assertArrayEquals(expected, getDisplayNameSelectionArgs(" foo bar "));
+        assertArrayEquals(expected, getDisplayNameSelectionArgs("foo\t bar"));
+        assertArrayEquals(expected, getDisplayNameSelectionArgs("\t foo\t bar\t "));
+    }
+
+    private void assertArrayEquals(String[] expected, String[] actual) {
+        if (expected == null && actual == null) return;
+        if (expected == null || actual == null) fail("expected:" + expected + " but was:" + actual);
+        assertEquals(Arrays.toString(expected), Arrays.toString(actual));
+    }
+}
\ No newline at end of file