Added support for quoted phrase search, string escaping in calendar provider

 - We can now parse queries that include quoted phrases
 - We also escape special characters like "%" and "_"

Change-Id: Iaf2519aa1b1af4753a2bf3b9bea342379186dac2
diff --git a/src/com/android/providers/calendar/CalendarProvider2.java b/src/com/android/providers/calendar/CalendarProvider2.java
index 11811bf..e656a26 100644
--- a/src/com/android/providers/calendar/CalendarProvider2.java
+++ b/src/com/android/providers/calendar/CalendarProvider2.java
@@ -64,8 +64,10 @@
 import java.util.Arrays;
 import java.util.HashMap;
 import java.util.HashSet;
+import java.util.List;
 import java.util.Set;
 import java.util.TimeZone;
+import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
 /**
@@ -269,9 +271,29 @@
 
     /**
      * A regex for describing how we split search queries into tokens.
-     * Currently splits on whitespace and punctuation
+     * Keeps quoted phrases as one token.
+     *
+     *   "one \"two three\"" ==> ["one" "two three"]
      */
-    private static final Pattern SEARCH_TOKEN_PATTERN = Pattern.compile("[\\s,.!?]+");
+    private static final Pattern SEARCH_TOKEN_PATTERN =
+        Pattern.compile("[^\\s\"'.?!,]+|" // first part matches unquoted words
+                      + "\"([^\"]*)\"");  // second part matches quoted phrases
+    /**
+     * A special character that was use to escape potentially problematic
+     * characters in search queries.
+     *
+     * Note: do not use backslash for this, as it interferes with the regex
+     * escaping mechanism.
+     */
+    private static final String SEARCH_ESCAPE_CHAR = "#";
+
+    /**
+     * A regex for matching any characters in an incoming search query that we
+     * need to escape with {@link #SEARCH_ESCAPE_CHAR}, including the escape
+     * character itself.
+     */
+    private static final Pattern SEARCH_ESCAPE_PATTERN =
+        Pattern.compile("([%_" + SEARCH_ESCAPE_CHAR + "])");
 
     private static final String[] SEARCH_COLUMNS = new String[] {
         Calendar.Events.TITLE,
@@ -860,16 +882,40 @@
     }
 
     /**
+     * Escape any special characters in the search token
+     * @param token the token to escape
+     * @return the escaped token
+     */
+    @VisibleForTesting
+    String escapeSearchToken(String token) {
+        Matcher matcher = SEARCH_ESCAPE_PATTERN.matcher(token);
+        return matcher.replaceAll(SEARCH_ESCAPE_CHAR + "$1");
+    }
+
+    /**
      * Splits the search query into individual search tokens based on whitespace
-     * and punctuation.
+     * and punctuation. Leaves both single quoted and double quoted strings
+     * intact.
      *
-     * TODO Support quoted phrases
      * @param query the search query
      * @return an array of tokens from the search query
      */
     @VisibleForTesting
     String[] tokenizeSearchQuery(String query) {
-        return SEARCH_TOKEN_PATTERN.split(query);
+        List<String> matchList = new ArrayList<String>();
+        Matcher matcher = SEARCH_TOKEN_PATTERN.matcher(query);
+        String token;
+        while (matcher.find()) {
+            if (matcher.group(1) != null) {
+                // double quoted string
+                token = matcher.group(1);
+            } else {
+                // unquoted token
+                token = matcher.group();
+            }
+            matchList.add(escapeSearchToken(token));
+        }
+        return matchList.toArray(new String[matchList.size()]);
     }
 
     /**
@@ -902,7 +948,9 @@
             sb.append("(");
             for (int i = 0; i < SEARCH_COLUMNS.length; i++) {
                 sb.append(SEARCH_COLUMNS[i]);
-                sb.append(" LIKE ? ");
+                sb.append(" LIKE ? ESCAPE \"");
+                sb.append(SEARCH_ESCAPE_CHAR);
+                sb.append("\" ");
                 if (i < SEARCH_COLUMNS.length - 1) {
                     sb.append("OR ");
                 }
@@ -934,10 +982,11 @@
         qb.setTables(INSTANCE_QUERY_TABLES);
         qb.setProjectionMap(sInstancesProjectionMap);
 
-
-        String[] tokens = SEARCH_TOKEN_PATTERN.split(query);
+        String[] tokens = tokenizeSearchQuery(query);
         String[] selectionArgs = constructSearchArgs(tokens, rangeBegin, rangeEnd);
-        qb.appendWhere(constructSearchWhere(tokens));
+        String searchWhere = constructSearchWhere(tokens);
+
+        qb.appendWhere(searchWhere);
 
         if (searchByDay) {
             // Convert the first and last Julian day range to a range that uses
diff --git a/tests/src/com/android/providers/calendar/CalendarProvider2Test.java b/tests/src/com/android/providers/calendar/CalendarProvider2Test.java
index 438c334..d2ebde4 100644
--- a/tests/src/com/android/providers/calendar/CalendarProvider2Test.java
+++ b/tests/src/com/android/providers/calendar/CalendarProvider2Test.java
@@ -1270,15 +1270,50 @@
     }
 
     @SmallTest @Smoke
+    public void testEscapeSearchToken() {
+        String token = "test";
+        String expected = "test";
+        assertEquals(expected, mProvider.escapeSearchToken(token));
+
+        token = "%";
+        expected = "#%";
+        assertEquals(expected, mProvider.escapeSearchToken(token));
+
+        token = "_";
+        expected = "#_";
+        assertEquals(expected, mProvider.escapeSearchToken(token));
+
+        token = "#";
+        expected = "##";
+        assertEquals(expected, mProvider.escapeSearchToken(token));
+
+        token = "##";
+        expected = "####";
+        assertEquals(expected, mProvider.escapeSearchToken(token));
+
+        token = "%_#";
+        expected = "#%#_##";
+        assertEquals(expected, mProvider.escapeSearchToken(token));
+
+        token = "blah%blah";
+        expected = "blah#%blah";
+        assertEquals(expected, mProvider.escapeSearchToken(token));
+    }
+
+    @SmallTest @Smoke
     public void testTokenizeSearchQuery() {
         String query = "";
-        String[] expectedTokens = new String[] {""};
+        String[] expectedTokens = new String[] {};
         assertArrayEquals(expectedTokens, mProvider.tokenizeSearchQuery(query));
 
         query = "a";
         expectedTokens = new String[] {"a"};
         assertArrayEquals(expectedTokens, mProvider.tokenizeSearchQuery(query));
 
+        query = "word";
+        expectedTokens = new String[] {"word"};
+        assertArrayEquals(expectedTokens, mProvider.tokenizeSearchQuery(query));
+
         query = "two words";
         expectedTokens = new String[] {"two", "words"};
         assertArrayEquals(expectedTokens, mProvider.tokenizeSearchQuery(query));
@@ -1286,13 +1321,38 @@
         query = "test, punctuation.";
         expectedTokens = new String[] {"test", "punctuation"};
         assertArrayEquals(expectedTokens, mProvider.tokenizeSearchQuery(query));
+
+        query = "\"test phrase\"";
+        expectedTokens = new String[] {"test phrase"};
+        assertArrayEquals(expectedTokens, mProvider.tokenizeSearchQuery(query));
+
+        query = "unquoted \"this is quoted\"";
+        expectedTokens = new String[] {"unquoted", "this is quoted"};
+        assertArrayEquals(expectedTokens, mProvider.tokenizeSearchQuery(query));
+
+        query = " \"this is quoted\"  unquoted ";
+        expectedTokens = new String[] {"this is quoted", "unquoted"};
+        assertArrayEquals(expectedTokens, mProvider.tokenizeSearchQuery(query));
+
+        query = "escap%e m_e";
+        expectedTokens = new String[] {"escap#%e", "m#_e"};
+        assertArrayEquals(expectedTokens, mProvider.tokenizeSearchQuery(query));
+
+        query = "'a bunch' of malformed\" things";
+        expectedTokens = new String[] {"a", "bunch", "of", "malformed", "things"};
+        assertArrayEquals(expectedTokens, mProvider.tokenizeSearchQuery(query));
+
+        query = "''''''....,.''trim punctuation";
+        expectedTokens = new String[] {"trim", "punctuation"};
+        assertArrayEquals(expectedTokens, mProvider.tokenizeSearchQuery(query));
     }
 
     @SmallTest @Smoke
     public void testConstructSearchWhere() {
         String[] tokens = new String[] {"red"};
-        String expected = "(title LIKE ? OR description LIKE ? OR eventLocation"
-                + " LIKE ? ) AND ";
+        String expected = "(title LIKE ? ESCAPE \"#\" OR "
+                + "description LIKE ? ESCAPE \"#\" OR "
+                + "eventLocation LIKE ? ESCAPE \"#\" ) AND ";
         assertEquals(expected, mProvider.constructSearchWhere(tokens));
 
         tokens = new String[] {};
@@ -1300,15 +1360,21 @@
         assertEquals(expected, mProvider.constructSearchWhere(tokens));
 
         tokens = new String[] {"red", "green"};
-        expected = "(title LIKE ? OR description LIKE ? OR eventLocation"
-                + " LIKE ? ) AND (title LIKE ? OR description LIKE ? OR "
-                + "eventLocation LIKE ? ) AND ";
+        expected = "(title LIKE ? ESCAPE \"#\" OR "
+                + "description LIKE ? ESCAPE \"#\" OR "
+                + "eventLocation LIKE ? ESCAPE \"#\" ) AND "
+                + "(title LIKE ? ESCAPE \"#\" OR "
+                + "description LIKE ? ESCAPE \"#\" OR "
+                + "eventLocation LIKE ? ESCAPE \"#\" ) AND ";
         assertEquals(expected, mProvider.constructSearchWhere(tokens));
 
         tokens = new String[] {"red blue", "green"};
-        expected = "(title LIKE ? OR description LIKE ? OR eventLocation"
-                + " LIKE ? ) AND (title LIKE ? OR description LIKE ? OR "
-                + "eventLocation LIKE ? ) AND ";
+        expected = "(title LIKE ? ESCAPE \"#\" OR "
+                + "description LIKE ? ESCAPE \"#\" OR "
+                + "eventLocation LIKE ? ESCAPE \"#\" ) AND "
+                + "(title LIKE ? ESCAPE \"#\" OR "
+                + "description LIKE ? ESCAPE \"#\" OR "
+                + "eventLocation LIKE ? ESCAPE \"#\" ) AND ";
         assertEquals(expected, mProvider.constructSearchWhere(tokens));
     }
 
@@ -1458,6 +1524,42 @@
                 cursor.close();
             }
         }
+
+        try {
+            cursor = Instances.query(mResolver, PROJECTION,
+                    startMs - DateUtils.YEAR_IN_MILLIS,
+                    startMs + DateUtils.HOUR_IN_MILLIS,
+                    "\"search purple\"", where, orderBy);
+            assertEquals(1, cursor.getCount());
+        } finally {
+            if (cursor != null) {
+                cursor.close();
+            }
+        }
+
+        try {
+            cursor = Instances.query(mResolver, PROJECTION,
+                    startMs - DateUtils.YEAR_IN_MILLIS,
+                    startMs + DateUtils.HOUR_IN_MILLIS,
+                    "\"purple search\"", where, orderBy);
+            assertEquals(0, cursor.getCount());
+        } finally {
+            if (cursor != null) {
+                cursor.close();
+            }
+        }
+
+        try {
+            cursor = Instances.query(mResolver, PROJECTION,
+                    startMs - DateUtils.YEAR_IN_MILLIS,
+                    startMs + DateUtils.HOUR_IN_MILLIS,
+                    "%", where, orderBy);
+            assertEquals(0, cursor.getCount());
+        } finally {
+            if (cursor != null) {
+                cursor.close();
+            }
+        }
     }
 
     public void testEntityQuery() throws Exception {