Home | History | Annotate | Download | only in dialpad
      1 /*
      2  * Copyright (C) 2012 The Android Open Source Project
      3  *
      4  * Licensed under the Apache License, Version 2.0 (the "License");
      5  * you may not use this file except in compliance with the License.
      6  * You may obtain a copy of the License at
      7  *
      8  *      http://www.apache.org/licenses/LICENSE-2.0
      9  *
     10  * Unless required by applicable law or agreed to in writing, software
     11  * distributed under the License is distributed on an "AS IS" BASIS,
     12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     13  * See the License for the specific language governing permissions and
     14  * limitations under the License.
     15  */
     16 
     17 package com.android.dialer.dialpad;
     18 
     19 import android.text.TextUtils;
     20 
     21 import com.android.dialer.dialpad.SmartDialPrefix.PhoneNumberTokens;
     22 
     23 import com.google.common.annotations.VisibleForTesting;
     24 import com.google.common.collect.Lists;
     25 
     26 import java.util.ArrayList;
     27 
     28 /**
     29  * {@link #SmartDialNameMatcher} contains utility functions to remove accents from accented
     30  * characters and normalize a phone number. It also contains the matching logic that determines if
     31  * a contact's display name matches a numeric query. The boolean variable
     32  * {@link #ALLOW_INITIAL_MATCH} controls the behavior of the matching logic and determines
     33  * whether we allow matches like 57 - (J)ohn (S)mith.
     34  */
     35 public class SmartDialNameMatcher {
     36 
     37     private String mQuery;
     38 
     39     // Whether or not we allow matches like 57 - (J)ohn (S)mith
     40     private static final boolean ALLOW_INITIAL_MATCH = true;
     41 
     42     // The maximum length of the initial we will match - typically set to 1 to minimize false
     43     // positives
     44     private static final int INITIAL_LENGTH_LIMIT = 1;
     45 
     46     private final ArrayList<SmartDialMatchPosition> mMatchPositions = Lists.newArrayList();
     47 
     48     public static final SmartDialMap LATIN_SMART_DIAL_MAP = new LatinSmartDialMap();
     49 
     50     private final SmartDialMap mMap;
     51 
     52     private String mNameMatchMask = "";
     53     private String mPhoneNumberMatchMask = "";
     54 
     55     @VisibleForTesting
     56     public SmartDialNameMatcher(String query) {
     57         this(query, LATIN_SMART_DIAL_MAP);
     58     }
     59 
     60     public SmartDialNameMatcher(String query, SmartDialMap map) {
     61         mQuery = query;
     62         mMap = map;
     63     }
     64 
     65     /**
     66      * Constructs empty highlight mask. Bit 0 at a position means there is no match, Bit 1 means
     67      * there is a match and should be highlighted in the TextView.
     68      * @param builder StringBuilder object
     69      * @param length Length of the desired mask.
     70      */
     71     private void constructEmptyMask(StringBuilder builder, int length) {
     72         for (int i = 0; i < length; ++i) {
     73             builder.append("0");
     74         }
     75     }
     76 
     77     /**
     78      * Replaces the 0-bit at a position with 1-bit, indicating that there is a match.
     79      * @param builder StringBuilder object.
     80      * @param matchPos Match Positions to mask as 1.
     81      */
     82     private void replaceBitInMask(StringBuilder builder, SmartDialMatchPosition matchPos) {
     83         for (int i = matchPos.start; i < matchPos.end; ++i) {
     84             builder.replace(i, i + 1, "1");
     85         }
     86     }
     87 
     88     /**
     89      * Strips a phone number of unnecessary characters (spaces, dashes, etc.)
     90      *
     91      * @param number Phone number we want to normalize
     92      * @return Phone number consisting of digits from 0-9
     93      */
     94     public static String normalizeNumber(String number, SmartDialMap map) {
     95         return normalizeNumber(number, 0, map);
     96     }
     97 
     98     /**
     99      * Strips a phone number of unnecessary characters (spaces, dashes, etc.)
    100      *
    101      * @param number Phone number we want to normalize
    102      * @param offset Offset to start from
    103      * @return Phone number consisting of digits from 0-9
    104      */
    105     public static String normalizeNumber(String number, int offset, SmartDialMap map) {
    106         final StringBuilder s = new StringBuilder();
    107         for (int i = offset; i < number.length(); i++) {
    108             char ch = number.charAt(i);
    109             if (map.isValidDialpadNumericChar(ch)) {
    110                 s.append(ch);
    111             }
    112         }
    113         return s.toString();
    114     }
    115 
    116     /**
    117      * Matches a phone number against a query. Let the test application overwrite the NANP setting.
    118      *
    119      * @param phoneNumber - Raw phone number
    120      * @param query - Normalized query (only contains numbers from 0-9)
    121      * @param useNanp - Overwriting nanp setting boolean, used for testing.
    122      * @return {@literal null} if the number and the query don't match, a valid
    123      *         SmartDialMatchPosition with the matching positions otherwise
    124      */
    125     @VisibleForTesting
    126     public SmartDialMatchPosition matchesNumber(String phoneNumber, String query, boolean useNanp) {
    127         StringBuilder builder = new StringBuilder();
    128         constructEmptyMask(builder, phoneNumber.length());
    129         mPhoneNumberMatchMask = builder.toString();
    130 
    131         // Try matching the number as is
    132         SmartDialMatchPosition matchPos = matchesNumberWithOffset(phoneNumber, query, 0);
    133         if (matchPos == null) {
    134             final PhoneNumberTokens phoneNumberTokens =
    135                     SmartDialPrefix.parsePhoneNumber(phoneNumber);
    136 
    137             if (phoneNumberTokens == null) {
    138                 return matchPos;
    139             }
    140             if (phoneNumberTokens.countryCodeOffset != 0) {
    141                 matchPos = matchesNumberWithOffset(phoneNumber, query,
    142                         phoneNumberTokens.countryCodeOffset);
    143             }
    144             if (matchPos == null && phoneNumberTokens.nanpCodeOffset != 0 && useNanp) {
    145                 matchPos = matchesNumberWithOffset(phoneNumber, query,
    146                         phoneNumberTokens.nanpCodeOffset);
    147             }
    148         }
    149         if (matchPos != null) {
    150             replaceBitInMask(builder, matchPos);
    151             mPhoneNumberMatchMask = builder.toString();
    152         }
    153         return matchPos;
    154     }
    155 
    156     /**
    157      * Matches a phone number against the saved query, taking care of formatting characters and also
    158      * taking into account country code prefixes and special NANP number treatment.
    159      *
    160      * @param phoneNumber - Raw phone number
    161      * @return {@literal null} if the number and the query don't match, a valid
    162      *         SmartDialMatchPosition with the matching positions otherwise
    163      */
    164     public SmartDialMatchPosition matchesNumber(String phoneNumber) {
    165         return matchesNumber(phoneNumber, mQuery, true);
    166     }
    167 
    168     /**
    169      * Matches a phone number against a query, taking care of formatting characters and also
    170      * taking into account country code prefixes and special NANP number treatment.
    171      *
    172      * @param phoneNumber - Raw phone number
    173      * @param query - Normalized query (only contains numbers from 0-9)
    174      * @return {@literal null} if the number and the query don't match, a valid
    175      *         SmartDialMatchPosition with the matching positions otherwise
    176      */
    177     public SmartDialMatchPosition matchesNumber(String phoneNumber, String query) {
    178         return matchesNumber(phoneNumber, query, true);
    179     }
    180 
    181     /**
    182      * Matches a phone number against a query, taking care of formatting characters
    183      *
    184      * @param phoneNumber - Raw phone number
    185      * @param query - Normalized query (only contains numbers from 0-9)
    186      * @param offset - The position in the number to start the match against (used to ignore
    187      * leading prefixes/country codes)
    188      * @return {@literal null} if the number and the query don't match, a valid
    189      *         SmartDialMatchPosition with the matching positions otherwise
    190      */
    191     private SmartDialMatchPosition matchesNumberWithOffset(String phoneNumber, String query,
    192             int offset) {
    193         if (TextUtils.isEmpty(phoneNumber) || TextUtils.isEmpty(query)) {
    194             return null;
    195         }
    196         int queryAt = 0;
    197         int numberAt = offset;
    198         for (int i = offset; i < phoneNumber.length(); i++) {
    199             if (queryAt == query.length()) {
    200                 break;
    201             }
    202             char ch = phoneNumber.charAt(i);
    203             if (mMap.isValidDialpadNumericChar(ch)) {
    204                 if (ch != query.charAt(queryAt)) {
    205                     return null;
    206                 }
    207                 queryAt++;
    208             } else {
    209                 if (queryAt == 0) {
    210                     // Found a separator before any part of the query was matched, so advance the
    211                     // offset to avoid prematurely highlighting separators before the rest of the
    212                     // query.
    213                     // E.g. don't highlight the first '-' if we're matching 1-510-111-1111 with
    214                     // '510'.
    215                     // However, if the current offset is 0, just include the beginning separators
    216                     // anyway, otherwise the highlighting ends up looking weird.
    217                     // E.g. if we're matching (510)-111-1111 with '510', we should include the
    218                     // first '('.
    219                     if (offset != 0) {
    220                         offset++;
    221                     }
    222                 }
    223             }
    224             numberAt++;
    225         }
    226         return new SmartDialMatchPosition(0 + offset, numberAt);
    227     }
    228 
    229     /**
    230      * This function iterates through each token in the display name, trying to match the query
    231      * to the numeric equivalent of the token.
    232      *
    233      * A token is defined as a range in the display name delimited by characters that have no
    234      * latin alphabet equivalents (e.g. spaces - ' ', periods - ',', underscores - '_' or chinese
    235      * characters - ''). Transliteration from non-latin characters to latin character will be
    236      * done on a best effort basis - e.g. '' - 'u'.
    237      *
    238      * For example,
    239      * the display name "Phillips Thomas Jr" contains three tokens: "phillips", "thomas", and "jr".
    240      *
    241      * A match must begin at the start of a token.
    242      * For example, typing 846(Tho) would match "Phillips Thomas", but 466(hom) would not.
    243      *
    244      * Also, a match can extend across tokens.
    245      * For example, typing 37337(FredS) would match (Fred S)mith.
    246      *
    247      * @param displayName The normalized(no accented characters) display name we intend to match
    248      * against.
    249      * @param query The string of digits that we want to match the display name to.
    250      * @param matchList An array list of {@link SmartDialMatchPosition}s that we add matched
    251      * positions to.
    252      * @return Returns true if a combination of the tokens in displayName match the query
    253      * string contained in query. If the function returns true, matchList will contain an
    254      * ArrayList of match positions (multiple matches correspond to initial matches).
    255      */
    256     @VisibleForTesting
    257     boolean matchesCombination(String displayName, String query,
    258             ArrayList<SmartDialMatchPosition> matchList) {
    259         StringBuilder builder = new StringBuilder();
    260         constructEmptyMask(builder, displayName.length());
    261         mNameMatchMask = builder.toString();
    262         final int nameLength = displayName.length();
    263         final int queryLength = query.length();
    264 
    265         if (nameLength < queryLength) {
    266             return false;
    267         }
    268 
    269         if (queryLength == 0) {
    270             return false;
    271         }
    272 
    273         // The current character index in displayName
    274         // E.g. 3 corresponds to 'd' in "Fred Smith"
    275         int nameStart = 0;
    276 
    277         // The current character in the query we are trying to match the displayName against
    278         int queryStart = 0;
    279 
    280         // The start position of the current token we are inspecting
    281         int tokenStart = 0;
    282 
    283         // The number of non-alphabetic characters we've encountered so far in the current match.
    284         // E.g. if we've currently matched 3733764849 to (Fred Smith W)illiam, then the
    285         // seperatorCount should be 2. This allows us to correctly calculate offsets for the match
    286         // positions
    287         int seperatorCount = 0;
    288 
    289         ArrayList<SmartDialMatchPosition> partial = new ArrayList<SmartDialMatchPosition>();
    290         // Keep going until we reach the end of displayName
    291         while (nameStart < nameLength && queryStart < queryLength) {
    292             char ch = displayName.charAt(nameStart);
    293             // Strip diacritics from accented characters if any
    294             ch = mMap.normalizeCharacter(ch);
    295             if (mMap.isValidDialpadCharacter(ch)) {
    296                 if (mMap.isValidDialpadAlphabeticChar(ch)) {
    297                     ch = mMap.getDialpadNumericCharacter(ch);
    298                 }
    299                 if (ch != query.charAt(queryStart)) {
    300                     // Failed to match the current character in the query.
    301 
    302                     // Case 1: Failed to match the first character in the query. Skip to the next
    303                     // token since there is no chance of this token matching the query.
    304 
    305                     // Case 2: Previous characters in the query matched, but the current character
    306                     // failed to match. This happened in the middle of a token. Skip to the next
    307                     // token since there is no chance of this token matching the query.
    308 
    309                     // Case 3: Previous characters in the query matched, but the current character
    310                     // failed to match. This happened right at the start of the current token. In
    311                     // this case, we should restart the query and try again with the current token.
    312                     // Otherwise, we would fail to match a query like "964"(yog) against a name
    313                     // Yo-Yoghurt because the query match would fail on the 3rd character, and
    314                     // then skip to the end of the "Yoghurt" token.
    315 
    316                     if (queryStart == 0 || mMap.isValidDialpadCharacter(mMap.normalizeCharacter(
    317                             displayName.charAt(nameStart - 1)))) {
    318                         // skip to the next token, in the case of 1 or 2.
    319                         while (nameStart < nameLength &&
    320                                 mMap.isValidDialpadCharacter(mMap.normalizeCharacter(
    321                                         displayName.charAt(nameStart)))) {
    322                             nameStart++;
    323                         }
    324                         nameStart++;
    325                     }
    326 
    327                     // Restart the query and set the correct token position
    328                     queryStart = 0;
    329                     seperatorCount = 0;
    330                     tokenStart = nameStart;
    331                 } else {
    332                     if (queryStart == queryLength - 1) {
    333 
    334                         // As much as possible, we prioritize a full token match over a sub token
    335                         // one so if we find a full token match, we can return right away
    336                         matchList.add(new SmartDialMatchPosition(
    337                                 tokenStart, queryLength + tokenStart + seperatorCount));
    338                         for (SmartDialMatchPosition match : matchList) {
    339                             replaceBitInMask(builder, match);
    340                         }
    341                         mNameMatchMask = builder.toString();
    342                         return true;
    343                     } else if (ALLOW_INITIAL_MATCH && queryStart < INITIAL_LENGTH_LIMIT) {
    344                         // we matched the first character.
    345                         // branch off and see if we can find another match with the remaining
    346                         // characters in the query string and the remaining tokens
    347                         // find the next separator in the query string
    348                         int j;
    349                         for (j = nameStart; j < nameLength; j++) {
    350                             if (!mMap.isValidDialpadCharacter(mMap.normalizeCharacter(
    351                                     displayName.charAt(j)))) {
    352                                 break;
    353                             }
    354                         }
    355                         // this means there is at least one character left after the separator
    356                         if (j < nameLength - 1) {
    357                             final String remainder = displayName.substring(j + 1);
    358                             final ArrayList<SmartDialMatchPosition> partialTemp =
    359                                     Lists.newArrayList();
    360                             if (matchesCombination(
    361                                     remainder, query.substring(queryStart + 1), partialTemp)) {
    362 
    363                                 // store the list of possible match positions
    364                                 SmartDialMatchPosition.advanceMatchPositions(partialTemp, j + 1);
    365                                 partialTemp.add(0,
    366                                         new SmartDialMatchPosition(nameStart, nameStart + 1));
    367                                 // we found a partial token match, store the data in a
    368                                 // temp buffer and return it if we end up not finding a full
    369                                 // token match
    370                                 partial = partialTemp;
    371                             }
    372                         }
    373                     }
    374                     nameStart++;
    375                     queryStart++;
    376                     // we matched the current character in the name against one in the query,
    377                     // continue and see if the rest of the characters match
    378                 }
    379             } else {
    380                 // found a separator, we skip this character and continue to the next one
    381                 nameStart++;
    382                 if (queryStart == 0) {
    383                     // This means we found a separator before the start of a token,
    384                     // so we should increment the token's start position to reflect its true
    385                     // start position
    386                     tokenStart = nameStart;
    387                 } else {
    388                     // Otherwise this separator was found in the middle of a token being matched,
    389                     // so increase the separator count
    390                     seperatorCount++;
    391                 }
    392             }
    393         }
    394         // if we have no complete match at this point, then we attempt to fall back to the partial
    395         // token match(if any). If we don't allow initial matching (ALLOW_INITIAL_MATCH = false)
    396         // then partial will always be empty.
    397         if (!partial.isEmpty()) {
    398             matchList.addAll(partial);
    399             for (SmartDialMatchPosition match : matchList) {
    400                 replaceBitInMask(builder, match);
    401             }
    402             mNameMatchMask = builder.toString();
    403             return true;
    404         }
    405         return false;
    406     }
    407 
    408     public boolean matches(String displayName) {
    409         mMatchPositions.clear();
    410         return matchesCombination(displayName, mQuery, mMatchPositions);
    411     }
    412 
    413     public ArrayList<SmartDialMatchPosition> getMatchPositions() {
    414         // Return a clone of mMatchPositions so that the caller can use it without
    415         // worrying about it changing
    416         return new ArrayList<SmartDialMatchPosition>(mMatchPositions);
    417     }
    418 
    419     public void setQuery(String query) {
    420         mQuery = query;
    421     }
    422 
    423     public String getNameMatchPositionsInString() {
    424         return mNameMatchMask;
    425     }
    426 
    427     public String getNumberMatchPositionsInString() {
    428         return mPhoneNumberMatchMask;
    429     }
    430 
    431     public String getQuery() {
    432         return mQuery;
    433     }
    434 }
    435