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 final 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                 if (matchPos != null) {
    139                     replaceBitInMask(builder, matchPos);
    140                     mPhoneNumberMatchMask = builder.toString();
    141                 }
    142                 return matchPos;
    143             }
    144             if (phoneNumberTokens.countryCodeOffset != 0) {
    145                 matchPos = matchesNumberWithOffset(phoneNumber, query,
    146                         phoneNumberTokens.countryCodeOffset);
    147             }
    148             if (matchPos == null && phoneNumberTokens.nanpCodeOffset != 0 && useNanp) {
    149                 matchPos = matchesNumberWithOffset(phoneNumber, query,
    150                         phoneNumberTokens.nanpCodeOffset);
    151             }
    152         }
    153         if (matchPos != null) {
    154             replaceBitInMask(builder, matchPos);
    155             mPhoneNumberMatchMask = builder.toString();
    156         }
    157         return matchPos;
    158     }
    159 
    160     /**
    161      * Matches a phone number against the saved query, taking care of formatting characters and also
    162      * taking into account country code prefixes and special NANP number treatment.
    163      *
    164      * @param phoneNumber - Raw phone number
    165      * @return {@literal null} if the number and the query don't match, a valid
    166      *         SmartDialMatchPosition with the matching positions otherwise
    167      */
    168     public SmartDialMatchPosition matchesNumber(String phoneNumber) {
    169         return matchesNumber(phoneNumber, mQuery, true);
    170     }
    171 
    172     /**
    173      * Matches a phone number against a query, taking care of formatting characters and also
    174      * taking into account country code prefixes and special NANP number treatment.
    175      *
    176      * @param phoneNumber - Raw phone number
    177      * @param query - Normalized query (only contains numbers from 0-9)
    178      * @return {@literal null} if the number and the query don't match, a valid
    179      *         SmartDialMatchPosition with the matching positions otherwise
    180      */
    181     public SmartDialMatchPosition matchesNumber(String phoneNumber, String query) {
    182         return matchesNumber(phoneNumber, query, true);
    183     }
    184 
    185     /**
    186      * Matches a phone number against a query, taking care of formatting characters
    187      *
    188      * @param phoneNumber - Raw phone number
    189      * @param query - Normalized query (only contains numbers from 0-9)
    190      * @param offset - The position in the number to start the match against (used to ignore
    191      * leading prefixes/country codes)
    192      * @return {@literal null} if the number and the query don't match, a valid
    193      *         SmartDialMatchPosition with the matching positions otherwise
    194      */
    195     private SmartDialMatchPosition matchesNumberWithOffset(String phoneNumber, String query,
    196             int offset) {
    197         if (TextUtils.isEmpty(phoneNumber) || TextUtils.isEmpty(query)) {
    198             return null;
    199         }
    200         int queryAt = 0;
    201         int numberAt = offset;
    202         for (int i = offset; i < phoneNumber.length(); i++) {
    203             if (queryAt == query.length()) {
    204                 break;
    205             }
    206             char ch = phoneNumber.charAt(i);
    207             if (mMap.isValidDialpadNumericChar(ch)) {
    208                 if (ch != query.charAt(queryAt)) {
    209                     return null;
    210                 }
    211                 queryAt++;
    212             } else {
    213                 if (queryAt == 0) {
    214                     // Found a separator before any part of the query was matched, so advance the
    215                     // offset to avoid prematurely highlighting separators before the rest of the
    216                     // query.
    217                     // E.g. don't highlight the first '-' if we're matching 1-510-111-1111 with
    218                     // '510'.
    219                     // However, if the current offset is 0, just include the beginning separators
    220                     // anyway, otherwise the highlighting ends up looking weird.
    221                     // E.g. if we're matching (510)-111-1111 with '510', we should include the
    222                     // first '('.
    223                     if (offset != 0) {
    224                         offset++;
    225                     }
    226                 }
    227             }
    228             numberAt++;
    229         }
    230         return new SmartDialMatchPosition(0 + offset, numberAt);
    231     }
    232 
    233     /**
    234      * This function iterates through each token in the display name, trying to match the query
    235      * to the numeric equivalent of the token.
    236      *
    237      * A token is defined as a range in the display name delimited by characters that have no
    238      * latin alphabet equivalents (e.g. spaces - ' ', periods - ',', underscores - '_' or chinese
    239      * characters - ''). Transliteration from non-latin characters to latin character will be
    240      * done on a best effort basis - e.g. '' - 'u'.
    241      *
    242      * For example,
    243      * the display name "Phillips Thomas Jr" contains three tokens: "phillips", "thomas", and "jr".
    244      *
    245      * A match must begin at the start of a token.
    246      * For example, typing 846(Tho) would match "Phillips Thomas", but 466(hom) would not.
    247      *
    248      * Also, a match can extend across tokens.
    249      * For example, typing 37337(FredS) would match (Fred S)mith.
    250      *
    251      * @param displayName The normalized(no accented characters) display name we intend to match
    252      * against.
    253      * @param query The string of digits that we want to match the display name to.
    254      * @param matchList An array list of {@link SmartDialMatchPosition}s that we add matched
    255      * positions to.
    256      * @return Returns true if a combination of the tokens in displayName match the query
    257      * string contained in query. If the function returns true, matchList will contain an
    258      * ArrayList of match positions (multiple matches correspond to initial matches).
    259      */
    260     @VisibleForTesting
    261     boolean matchesCombination(String displayName, String query,
    262             ArrayList<SmartDialMatchPosition> matchList) {
    263         StringBuilder builder = new StringBuilder();
    264         constructEmptyMask(builder, displayName.length());
    265         mNameMatchMask = builder.toString();
    266         final int nameLength = displayName.length();
    267         final int queryLength = query.length();
    268 
    269         if (nameLength < queryLength) {
    270             return false;
    271         }
    272 
    273         if (queryLength == 0) {
    274             return false;
    275         }
    276 
    277         // The current character index in displayName
    278         // E.g. 3 corresponds to 'd' in "Fred Smith"
    279         int nameStart = 0;
    280 
    281         // The current character in the query we are trying to match the displayName against
    282         int queryStart = 0;
    283 
    284         // The start position of the current token we are inspecting
    285         int tokenStart = 0;
    286 
    287         // The number of non-alphabetic characters we've encountered so far in the current match.
    288         // E.g. if we've currently matched 3733764849 to (Fred Smith W)illiam, then the
    289         // seperatorCount should be 2. This allows us to correctly calculate offsets for the match
    290         // positions
    291         int seperatorCount = 0;
    292 
    293         ArrayList<SmartDialMatchPosition> partial = new ArrayList<SmartDialMatchPosition>();
    294         // Keep going until we reach the end of displayName
    295         while (nameStart < nameLength && queryStart < queryLength) {
    296             char ch = displayName.charAt(nameStart);
    297             // Strip diacritics from accented characters if any
    298             ch = mMap.normalizeCharacter(ch);
    299             if (mMap.isValidDialpadCharacter(ch)) {
    300                 if (mMap.isValidDialpadAlphabeticChar(ch)) {
    301                     ch = mMap.getDialpadNumericCharacter(ch);
    302                 }
    303                 if (ch != query.charAt(queryStart)) {
    304                     // Failed to match the current character in the query.
    305 
    306                     // Case 1: Failed to match the first character in the query. Skip to the next
    307                     // token since there is no chance of this token matching the query.
    308 
    309                     // Case 2: Previous characters in the query matched, but the current character
    310                     // failed to match. This happened in the middle of a token. Skip to the next
    311                     // token since there is no chance of this token matching the query.
    312 
    313                     // Case 3: Previous characters in the query matched, but the current character
    314                     // failed to match. This happened right at the start of the current token. In
    315                     // this case, we should restart the query and try again with the current token.
    316                     // Otherwise, we would fail to match a query like "964"(yog) against a name
    317                     // Yo-Yoghurt because the query match would fail on the 3rd character, and
    318                     // then skip to the end of the "Yoghurt" token.
    319 
    320                     if (queryStart == 0 || mMap.isValidDialpadCharacter(mMap.normalizeCharacter(
    321                             displayName.charAt(nameStart - 1)))) {
    322                         // skip to the next token, in the case of 1 or 2.
    323                         while (nameStart < nameLength &&
    324                                 mMap.isValidDialpadCharacter(mMap.normalizeCharacter(
    325                                         displayName.charAt(nameStart)))) {
    326                             nameStart++;
    327                         }
    328                         nameStart++;
    329                     }
    330 
    331                     // Restart the query and set the correct token position
    332                     queryStart = 0;
    333                     seperatorCount = 0;
    334                     tokenStart = nameStart;
    335                 } else {
    336                     if (queryStart == queryLength - 1) {
    337 
    338                         // As much as possible, we prioritize a full token match over a sub token
    339                         // one so if we find a full token match, we can return right away
    340                         matchList.add(new SmartDialMatchPosition(
    341                                 tokenStart, queryLength + tokenStart + seperatorCount));
    342                         for (SmartDialMatchPosition match : matchList) {
    343                             replaceBitInMask(builder, match);
    344                         }
    345                         mNameMatchMask = builder.toString();
    346                         return true;
    347                     } else if (ALLOW_INITIAL_MATCH && queryStart < INITIAL_LENGTH_LIMIT) {
    348                         // we matched the first character.
    349                         // branch off and see if we can find another match with the remaining
    350                         // characters in the query string and the remaining tokens
    351                         // find the next separator in the query string
    352                         int j;
    353                         for (j = nameStart; j < nameLength; j++) {
    354                             if (!mMap.isValidDialpadCharacter(mMap.normalizeCharacter(
    355                                     displayName.charAt(j)))) {
    356                                 break;
    357                             }
    358                         }
    359                         // this means there is at least one character left after the separator
    360                         if (j < nameLength - 1) {
    361                             final String remainder = displayName.substring(j + 1);
    362                             final ArrayList<SmartDialMatchPosition> partialTemp =
    363                                     Lists.newArrayList();
    364                             if (matchesCombination(
    365                                     remainder, query.substring(queryStart + 1), partialTemp)) {
    366 
    367                                 // store the list of possible match positions
    368                                 SmartDialMatchPosition.advanceMatchPositions(partialTemp, j + 1);
    369                                 partialTemp.add(0,
    370                                         new SmartDialMatchPosition(nameStart, nameStart + 1));
    371                                 // we found a partial token match, store the data in a
    372                                 // temp buffer and return it if we end up not finding a full
    373                                 // token match
    374                                 partial = partialTemp;
    375                             }
    376                         }
    377                     }
    378                     nameStart++;
    379                     queryStart++;
    380                     // we matched the current character in the name against one in the query,
    381                     // continue and see if the rest of the characters match
    382                 }
    383             } else {
    384                 // found a separator, we skip this character and continue to the next one
    385                 nameStart++;
    386                 if (queryStart == 0) {
    387                     // This means we found a separator before the start of a token,
    388                     // so we should increment the token's start position to reflect its true
    389                     // start position
    390                     tokenStart = nameStart;
    391                 } else {
    392                     // Otherwise this separator was found in the middle of a token being matched,
    393                     // so increase the separator count
    394                     seperatorCount++;
    395                 }
    396             }
    397         }
    398         // if we have no complete match at this point, then we attempt to fall back to the partial
    399         // token match(if any). If we don't allow initial matching (ALLOW_INITIAL_MATCH = false)
    400         // then partial will always be empty.
    401         if (!partial.isEmpty()) {
    402             matchList.addAll(partial);
    403             for (SmartDialMatchPosition match : matchList) {
    404                 replaceBitInMask(builder, match);
    405             }
    406             mNameMatchMask = builder.toString();
    407             return true;
    408         }
    409         return false;
    410     }
    411 
    412     public boolean matches(String displayName) {
    413         mMatchPositions.clear();
    414         return matchesCombination(displayName, mQuery, mMatchPositions);
    415     }
    416 
    417     public ArrayList<SmartDialMatchPosition> getMatchPositions() {
    418         // Return a clone of mMatchPositions so that the caller can use it without
    419         // worrying about it changing
    420         return new ArrayList<SmartDialMatchPosition>(mMatchPositions);
    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