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