Home | History | Annotate | Download | only in spellcheck
      1 /*
      2  * Copyright (C) 2011 The Android Open Source Project
      3  *
      4  * Licensed under the Apache License, Version 2.0 (the "License"); you may not
      5  * use this file except in compliance with the License. You may obtain a copy of
      6  * 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, WITHOUT
     12  * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
     13  * License for the specific language governing permissions and limitations under
     14  * the License.
     15  */
     16 
     17 package com.android.inputmethod.latin.spellcheck;
     18 
     19 import android.content.Intent;
     20 import android.content.SharedPreferences;
     21 import android.preference.PreferenceManager;
     22 import android.service.textservice.SpellCheckerService;
     23 import android.text.TextUtils;
     24 import android.util.Log;
     25 import android.util.LruCache;
     26 import android.view.textservice.SentenceSuggestionsInfo;
     27 import android.view.textservice.SuggestionsInfo;
     28 import android.view.textservice.TextInfo;
     29 
     30 import com.android.inputmethod.compat.SuggestionsInfoCompatUtils;
     31 import com.android.inputmethod.keyboard.ProximityInfo;
     32 import com.android.inputmethod.latin.BinaryDictionary;
     33 import com.android.inputmethod.latin.Dictionary;
     34 import com.android.inputmethod.latin.Dictionary.WordCallback;
     35 import com.android.inputmethod.latin.DictionaryCollection;
     36 import com.android.inputmethod.latin.DictionaryFactory;
     37 import com.android.inputmethod.latin.LatinIME;
     38 import com.android.inputmethod.latin.LocaleUtils;
     39 import com.android.inputmethod.latin.R;
     40 import com.android.inputmethod.latin.StringUtils;
     41 import com.android.inputmethod.latin.SynchronouslyLoadedContactsBinaryDictionary;
     42 import com.android.inputmethod.latin.SynchronouslyLoadedContactsDictionary;
     43 import com.android.inputmethod.latin.SynchronouslyLoadedUserBinaryDictionary;
     44 import com.android.inputmethod.latin.SynchronouslyLoadedUserDictionary;
     45 import com.android.inputmethod.latin.WhitelistDictionary;
     46 import com.android.inputmethod.latin.WordComposer;
     47 
     48 import java.lang.ref.WeakReference;
     49 import java.util.ArrayList;
     50 import java.util.Arrays;
     51 import java.util.Collections;
     52 import java.util.HashSet;
     53 import java.util.Iterator;
     54 import java.util.Locale;
     55 import java.util.Map;
     56 import java.util.TreeMap;
     57 
     58 /**
     59  * Service for spell checking, using LatinIME's dictionaries and mechanisms.
     60  */
     61 public class AndroidSpellCheckerService extends SpellCheckerService
     62         implements SharedPreferences.OnSharedPreferenceChangeListener {
     63     private static final String TAG = AndroidSpellCheckerService.class.getSimpleName();
     64     private static final boolean DBG = false;
     65     private static final int POOL_SIZE = 2;
     66 
     67     public static final String PREF_USE_CONTACTS_KEY = "pref_spellcheck_use_contacts";
     68 
     69     private static final int CAPITALIZE_NONE = 0; // No caps, or mixed case
     70     private static final int CAPITALIZE_FIRST = 1; // First only
     71     private static final int CAPITALIZE_ALL = 2; // All caps
     72 
     73     private final static String[] EMPTY_STRING_ARRAY = new String[0];
     74     private Map<String, DictionaryPool> mDictionaryPools =
     75             Collections.synchronizedMap(new TreeMap<String, DictionaryPool>());
     76     private Map<String, Dictionary> mUserDictionaries =
     77             Collections.synchronizedMap(new TreeMap<String, Dictionary>());
     78     private Map<String, Dictionary> mWhitelistDictionaries =
     79             Collections.synchronizedMap(new TreeMap<String, Dictionary>());
     80     private Dictionary mContactsDictionary;
     81 
     82     // The threshold for a candidate to be offered as a suggestion.
     83     private float mSuggestionThreshold;
     84     // The threshold for a suggestion to be considered "recommended".
     85     private float mRecommendedThreshold;
     86     // Whether to use the contacts dictionary
     87     private boolean mUseContactsDictionary;
     88     private final Object mUseContactsLock = new Object();
     89 
     90     private final HashSet<WeakReference<DictionaryCollection>> mDictionaryCollectionsList =
     91             new HashSet<WeakReference<DictionaryCollection>>();
     92 
     93     public static final int SCRIPT_LATIN = 0;
     94     public static final int SCRIPT_CYRILLIC = 1;
     95     private static final String SINGLE_QUOTE = "\u0027";
     96     private static final String APOSTROPHE = "\u2019";
     97     private static final TreeMap<String, Integer> mLanguageToScript;
     98     static {
     99         // List of the supported languages and their associated script. We won't check
    100         // words written in another script than the selected script, because we know we
    101         // don't have those in our dictionary so we will underline everything and we
    102         // will never have any suggestions, so it makes no sense checking them, and this
    103         // is done in {@link #shouldFilterOut}. Also, the script is used to choose which
    104         // proximity to pass to the dictionary descent algorithm.
    105         // IMPORTANT: this only contains languages - do not write countries in there.
    106         // Only the language is searched from the map.
    107         mLanguageToScript = new TreeMap<String, Integer>();
    108         mLanguageToScript.put("en", SCRIPT_LATIN);
    109         mLanguageToScript.put("fr", SCRIPT_LATIN);
    110         mLanguageToScript.put("de", SCRIPT_LATIN);
    111         mLanguageToScript.put("nl", SCRIPT_LATIN);
    112         mLanguageToScript.put("cs", SCRIPT_LATIN);
    113         mLanguageToScript.put("es", SCRIPT_LATIN);
    114         mLanguageToScript.put("it", SCRIPT_LATIN);
    115         mLanguageToScript.put("hr", SCRIPT_LATIN);
    116         mLanguageToScript.put("pt", SCRIPT_LATIN);
    117         mLanguageToScript.put("ru", SCRIPT_CYRILLIC);
    118         // TODO: Make a persian proximity, and activate the Farsi subtype.
    119         // mLanguageToScript.put("fa", SCRIPT_PERSIAN);
    120     }
    121 
    122     @Override public void onCreate() {
    123         super.onCreate();
    124         mSuggestionThreshold =
    125                 Float.parseFloat(getString(R.string.spellchecker_suggestion_threshold_value));
    126         mRecommendedThreshold =
    127                 Float.parseFloat(getString(R.string.spellchecker_recommended_threshold_value));
    128         final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
    129         prefs.registerOnSharedPreferenceChangeListener(this);
    130         onSharedPreferenceChanged(prefs, PREF_USE_CONTACTS_KEY);
    131     }
    132 
    133     private static int getScriptFromLocale(final Locale locale) {
    134         final Integer script = mLanguageToScript.get(locale.getLanguage());
    135         if (null == script) {
    136             throw new RuntimeException("We have been called with an unsupported language: \""
    137                     + locale.getLanguage() + "\". Framework bug?");
    138         }
    139         return script;
    140     }
    141 
    142     @Override
    143     public void onSharedPreferenceChanged(final SharedPreferences prefs, final String key) {
    144         if (!PREF_USE_CONTACTS_KEY.equals(key)) return;
    145         synchronized(mUseContactsLock) {
    146             mUseContactsDictionary = prefs.getBoolean(PREF_USE_CONTACTS_KEY, true);
    147             if (mUseContactsDictionary) {
    148                 startUsingContactsDictionaryLocked();
    149             } else {
    150                 stopUsingContactsDictionaryLocked();
    151             }
    152         }
    153     }
    154 
    155     private void startUsingContactsDictionaryLocked() {
    156         if (null == mContactsDictionary) {
    157             if (LatinIME.USE_BINARY_CONTACTS_DICTIONARY) {
    158                 // TODO: use the right locale for each session
    159                 mContactsDictionary =
    160                         new SynchronouslyLoadedContactsBinaryDictionary(this, Locale.getDefault());
    161             } else {
    162                 mContactsDictionary = new SynchronouslyLoadedContactsDictionary(this);
    163             }
    164         }
    165         final Iterator<WeakReference<DictionaryCollection>> iterator =
    166                 mDictionaryCollectionsList.iterator();
    167         while (iterator.hasNext()) {
    168             final WeakReference<DictionaryCollection> dictRef = iterator.next();
    169             final DictionaryCollection dict = dictRef.get();
    170             if (null == dict) {
    171                 iterator.remove();
    172             } else {
    173                 dict.addDictionary(mContactsDictionary);
    174             }
    175         }
    176     }
    177 
    178     private void stopUsingContactsDictionaryLocked() {
    179         if (null == mContactsDictionary) return;
    180         final Dictionary contactsDict = mContactsDictionary;
    181         // TODO: revert to the concrete type when USE_BINARY_CONTACTS_DICTIONARY is no longer needed
    182         mContactsDictionary = null;
    183         final Iterator<WeakReference<DictionaryCollection>> iterator =
    184                 mDictionaryCollectionsList.iterator();
    185         while (iterator.hasNext()) {
    186             final WeakReference<DictionaryCollection> dictRef = iterator.next();
    187             final DictionaryCollection dict = dictRef.get();
    188             if (null == dict) {
    189                 iterator.remove();
    190             } else {
    191                 dict.removeDictionary(contactsDict);
    192             }
    193         }
    194         contactsDict.close();
    195     }
    196 
    197     @Override
    198     public Session createSession() {
    199         return new AndroidSpellCheckerSession(this);
    200     }
    201 
    202     private static SuggestionsInfo getNotInDictEmptySuggestions() {
    203         return new SuggestionsInfo(0, EMPTY_STRING_ARRAY);
    204     }
    205 
    206     private static SuggestionsInfo getInDictEmptySuggestions() {
    207         return new SuggestionsInfo(SuggestionsInfo.RESULT_ATTR_IN_THE_DICTIONARY,
    208                 EMPTY_STRING_ARRAY);
    209     }
    210 
    211     private static class SuggestionsGatherer implements WordCallback {
    212         public static class Result {
    213             public final String[] mSuggestions;
    214             public final boolean mHasRecommendedSuggestions;
    215             public Result(final String[] gatheredSuggestions,
    216                     final boolean hasRecommendedSuggestions) {
    217                 mSuggestions = gatheredSuggestions;
    218                 mHasRecommendedSuggestions = hasRecommendedSuggestions;
    219             }
    220         }
    221 
    222         private final ArrayList<CharSequence> mSuggestions;
    223         private final int[] mScores;
    224         private final String mOriginalText;
    225         private final float mSuggestionThreshold;
    226         private final float mRecommendedThreshold;
    227         private final int mMaxLength;
    228         private int mLength = 0;
    229 
    230         // The two following attributes are only ever filled if the requested max length
    231         // is 0 (or less, which is treated the same).
    232         private String mBestSuggestion = null;
    233         private int mBestScore = Integer.MIN_VALUE; // As small as possible
    234 
    235         SuggestionsGatherer(final String originalText, final float suggestionThreshold,
    236                 final float recommendedThreshold, final int maxLength) {
    237             mOriginalText = originalText;
    238             mSuggestionThreshold = suggestionThreshold;
    239             mRecommendedThreshold = recommendedThreshold;
    240             mMaxLength = maxLength;
    241             mSuggestions = new ArrayList<CharSequence>(maxLength + 1);
    242             mScores = new int[mMaxLength];
    243         }
    244 
    245         @Override
    246         synchronized public boolean addWord(char[] word, int wordOffset, int wordLength, int score,
    247                 int dicTypeId, int dataType) {
    248             final int positionIndex = Arrays.binarySearch(mScores, 0, mLength, score);
    249             // binarySearch returns the index if the element exists, and -<insertion index> - 1
    250             // if it doesn't. See documentation for binarySearch.
    251             final int insertIndex = positionIndex >= 0 ? positionIndex : -positionIndex - 1;
    252 
    253             if (insertIndex == 0 && mLength >= mMaxLength) {
    254                 // In the future, we may want to keep track of the best suggestion score even if
    255                 // we are asked for 0 suggestions. In this case, we can use the following
    256                 // (tested) code to keep it:
    257                 // If the maxLength is 0 (should never be less, but if it is, it's treated as 0)
    258                 // then we need to keep track of the best suggestion in mBestScore and
    259                 // mBestSuggestion. This is so that we know whether the best suggestion makes
    260                 // the score cutoff, since we need to know that to return a meaningful
    261                 // looksLikeTypo.
    262                 // if (0 >= mMaxLength) {
    263                 //     if (score > mBestScore) {
    264                 //         mBestScore = score;
    265                 //         mBestSuggestion = new String(word, wordOffset, wordLength);
    266                 //     }
    267                 // }
    268                 return true;
    269             }
    270             if (insertIndex >= mMaxLength) {
    271                 // We found a suggestion, but its score is too weak to be kept considering
    272                 // the suggestion limit.
    273                 return true;
    274             }
    275 
    276             // Compute the normalized score and skip this word if it's normalized score does not
    277             // make the threshold.
    278             final String wordString = new String(word, wordOffset, wordLength);
    279             final float normalizedScore =
    280                     BinaryDictionary.calcNormalizedScore(mOriginalText, wordString, score);
    281             if (normalizedScore < mSuggestionThreshold) {
    282                 if (DBG) Log.i(TAG, wordString + " does not make the score threshold");
    283                 return true;
    284             }
    285 
    286             if (mLength < mMaxLength) {
    287                 final int copyLen = mLength - insertIndex;
    288                 ++mLength;
    289                 System.arraycopy(mScores, insertIndex, mScores, insertIndex + 1, copyLen);
    290                 mSuggestions.add(insertIndex, wordString);
    291             } else {
    292                 System.arraycopy(mScores, 1, mScores, 0, insertIndex);
    293                 mSuggestions.add(insertIndex, wordString);
    294                 mSuggestions.remove(0);
    295             }
    296             mScores[insertIndex] = score;
    297 
    298             return true;
    299         }
    300 
    301         public Result getResults(final int capitalizeType, final Locale locale) {
    302             final String[] gatheredSuggestions;
    303             final boolean hasRecommendedSuggestions;
    304             if (0 == mLength) {
    305                 // Either we found no suggestions, or we found some BUT the max length was 0.
    306                 // If we found some mBestSuggestion will not be null. If it is null, then
    307                 // we found none, regardless of the max length.
    308                 if (null == mBestSuggestion) {
    309                     gatheredSuggestions = null;
    310                     hasRecommendedSuggestions = false;
    311                 } else {
    312                     gatheredSuggestions = EMPTY_STRING_ARRAY;
    313                     final float normalizedScore = BinaryDictionary.calcNormalizedScore(
    314                             mOriginalText, mBestSuggestion, mBestScore);
    315                     hasRecommendedSuggestions = (normalizedScore > mRecommendedThreshold);
    316                 }
    317             } else {
    318                 if (DBG) {
    319                     if (mLength != mSuggestions.size()) {
    320                         Log.e(TAG, "Suggestion size is not the same as stored mLength");
    321                     }
    322                     for (int i = mLength - 1; i >= 0; --i) {
    323                         Log.i(TAG, "" + mScores[i] + " " + mSuggestions.get(i));
    324                     }
    325                 }
    326                 Collections.reverse(mSuggestions);
    327                 StringUtils.removeDupes(mSuggestions);
    328                 if (CAPITALIZE_ALL == capitalizeType) {
    329                     for (int i = 0; i < mSuggestions.size(); ++i) {
    330                         // get(i) returns a CharSequence which is actually a String so .toString()
    331                         // should return the same object.
    332                         mSuggestions.set(i, mSuggestions.get(i).toString().toUpperCase(locale));
    333                     }
    334                 } else if (CAPITALIZE_FIRST == capitalizeType) {
    335                     for (int i = 0; i < mSuggestions.size(); ++i) {
    336                         // Likewise
    337                         mSuggestions.set(i, StringUtils.toTitleCase(
    338                                 mSuggestions.get(i).toString(), locale));
    339                     }
    340                 }
    341                 // This returns a String[], while toArray() returns an Object[] which cannot be cast
    342                 // into a String[].
    343                 gatheredSuggestions = mSuggestions.toArray(EMPTY_STRING_ARRAY);
    344 
    345                 final int bestScore = mScores[mLength - 1];
    346                 final CharSequence bestSuggestion = mSuggestions.get(0);
    347                 final float normalizedScore =
    348                         BinaryDictionary.calcNormalizedScore(
    349                                 mOriginalText, bestSuggestion.toString(), bestScore);
    350                 hasRecommendedSuggestions = (normalizedScore > mRecommendedThreshold);
    351                 if (DBG) {
    352                     Log.i(TAG, "Best suggestion : " + bestSuggestion + ", score " + bestScore);
    353                     Log.i(TAG, "Normalized score = " + normalizedScore
    354                             + " (threshold " + mRecommendedThreshold
    355                             + ") => hasRecommendedSuggestions = " + hasRecommendedSuggestions);
    356                 }
    357             }
    358             return new Result(gatheredSuggestions, hasRecommendedSuggestions);
    359         }
    360     }
    361 
    362     @Override
    363     public boolean onUnbind(final Intent intent) {
    364         closeAllDictionaries();
    365         return false;
    366     }
    367 
    368     private void closeAllDictionaries() {
    369         final Map<String, DictionaryPool> oldPools = mDictionaryPools;
    370         mDictionaryPools = Collections.synchronizedMap(new TreeMap<String, DictionaryPool>());
    371         final Map<String, Dictionary> oldUserDictionaries = mUserDictionaries;
    372         mUserDictionaries = Collections.synchronizedMap(new TreeMap<String, Dictionary>());
    373         final Map<String, Dictionary> oldWhitelistDictionaries = mWhitelistDictionaries;
    374         mWhitelistDictionaries = Collections.synchronizedMap(new TreeMap<String, Dictionary>());
    375         new Thread("spellchecker_close_dicts") {
    376             @Override
    377             public void run() {
    378                 for (DictionaryPool pool : oldPools.values()) {
    379                     pool.close();
    380                 }
    381                 for (Dictionary dict : oldUserDictionaries.values()) {
    382                     dict.close();
    383                 }
    384                 for (Dictionary dict : oldWhitelistDictionaries.values()) {
    385                     dict.close();
    386                 }
    387                 synchronized (mUseContactsLock) {
    388                     if (null != mContactsDictionary) {
    389                         // The synchronously loaded contacts dictionary should have been in one
    390                         // or several pools, but it is shielded against multiple closing and it's
    391                         // safe to call it several times.
    392                         final Dictionary dictToClose = mContactsDictionary;
    393                         // TODO: revert to the concrete type when USE_BINARY_CONTACTS_DICTIONARY
    394                         // is no longer needed
    395                         mContactsDictionary = null;
    396                         dictToClose.close();
    397                     }
    398                 }
    399             }
    400         }.start();
    401     }
    402 
    403     private DictionaryPool getDictionaryPool(final String locale) {
    404         DictionaryPool pool = mDictionaryPools.get(locale);
    405         if (null == pool) {
    406             final Locale localeObject = LocaleUtils.constructLocaleFromString(locale);
    407             pool = new DictionaryPool(POOL_SIZE, this, localeObject);
    408             mDictionaryPools.put(locale, pool);
    409         }
    410         return pool;
    411     }
    412 
    413     public DictAndProximity createDictAndProximity(final Locale locale) {
    414         final int script = getScriptFromLocale(locale);
    415         final ProximityInfo proximityInfo = ProximityInfo.createSpellCheckerProximityInfo(
    416                 SpellCheckerProximityInfo.getProximityForScript(script),
    417                 SpellCheckerProximityInfo.ROW_SIZE,
    418                 SpellCheckerProximityInfo.PROXIMITY_GRID_WIDTH,
    419                 SpellCheckerProximityInfo.PROXIMITY_GRID_HEIGHT);
    420         final DictionaryCollection dictionaryCollection =
    421                 DictionaryFactory.createMainDictionaryFromManager(this, locale,
    422                         true /* useFullEditDistance */);
    423         final String localeStr = locale.toString();
    424         Dictionary userDictionary = mUserDictionaries.get(localeStr);
    425         if (null == userDictionary) {
    426             if (LatinIME.USE_BINARY_USER_DICTIONARY) {
    427                 userDictionary = new SynchronouslyLoadedUserBinaryDictionary(this, localeStr, true);
    428             } else {
    429                 userDictionary = new SynchronouslyLoadedUserDictionary(this, localeStr, true);
    430             }
    431             mUserDictionaries.put(localeStr, userDictionary);
    432         }
    433         dictionaryCollection.addDictionary(userDictionary);
    434         Dictionary whitelistDictionary = mWhitelistDictionaries.get(localeStr);
    435         if (null == whitelistDictionary) {
    436             whitelistDictionary = new WhitelistDictionary(this, locale);
    437             mWhitelistDictionaries.put(localeStr, whitelistDictionary);
    438         }
    439         dictionaryCollection.addDictionary(whitelistDictionary);
    440         synchronized (mUseContactsLock) {
    441             if (mUseContactsDictionary) {
    442                 if (null == mContactsDictionary) {
    443                     // TODO: revert to the concrete type when USE_BINARY_CONTACTS_DICTIONARY is no
    444                     // longer needed
    445                     if (LatinIME.USE_BINARY_CONTACTS_DICTIONARY) {
    446                         // TODO: use the right locale. We can't do it right now because the
    447                         // spell checker is reusing the contacts dictionary across sessions
    448                         // without regard for their locale, so we need to fix that first.
    449                         mContactsDictionary = new SynchronouslyLoadedContactsBinaryDictionary(this,
    450                                 Locale.getDefault());
    451                     } else {
    452                         mContactsDictionary = new SynchronouslyLoadedContactsDictionary(this);
    453                     }
    454                 }
    455             }
    456             dictionaryCollection.addDictionary(mContactsDictionary);
    457             mDictionaryCollectionsList.add(
    458                     new WeakReference<DictionaryCollection>(dictionaryCollection));
    459         }
    460         return new DictAndProximity(dictionaryCollection, proximityInfo);
    461     }
    462 
    463     // This method assumes the text is not empty or null.
    464     private static int getCapitalizationType(String text) {
    465         // If the first char is not uppercase, then the word is either all lower case,
    466         // and in either case we return CAPITALIZE_NONE.
    467         if (!Character.isUpperCase(text.codePointAt(0))) return CAPITALIZE_NONE;
    468         final int len = text.length();
    469         int capsCount = 1;
    470         for (int i = 1; i < len; i = text.offsetByCodePoints(i, 1)) {
    471             if (1 != capsCount && i != capsCount) break;
    472             if (Character.isUpperCase(text.codePointAt(i))) ++capsCount;
    473         }
    474         // We know the first char is upper case. So we want to test if either everything
    475         // else is lower case, or if everything else is upper case. If the string is
    476         // exactly one char long, then we will arrive here with capsCount 1, and this is
    477         // correct, too.
    478         if (1 == capsCount) return CAPITALIZE_FIRST;
    479         return (len == capsCount ? CAPITALIZE_ALL : CAPITALIZE_NONE);
    480     }
    481 
    482     private static class AndroidSpellCheckerSession extends Session {
    483         // Immutable, but need the locale which is not available in the constructor yet
    484         private DictionaryPool mDictionaryPool;
    485         // Likewise
    486         private Locale mLocale;
    487         // Cache this for performance
    488         private int mScript; // One of SCRIPT_LATIN or SCRIPT_CYRILLIC for now.
    489 
    490         private final AndroidSpellCheckerService mService;
    491 
    492         private final SuggestionsCache mSuggestionsCache = new SuggestionsCache();
    493 
    494         private static class SuggestionsParams {
    495             public final String[] mSuggestions;
    496             public final int mFlags;
    497             public SuggestionsParams(String[] suggestions, int flags) {
    498                 mSuggestions = suggestions;
    499                 mFlags = flags;
    500             }
    501         }
    502 
    503         private static class SuggestionsCache {
    504             private static final int MAX_CACHE_SIZE = 50;
    505             // TODO: support bigram
    506             private final LruCache<String, SuggestionsParams> mUnigramSuggestionsInfoCache =
    507                     new LruCache<String, SuggestionsParams>(MAX_CACHE_SIZE);
    508 
    509             public SuggestionsParams getSuggestionsFromCache(String query) {
    510                 return mUnigramSuggestionsInfoCache.get(query);
    511             }
    512 
    513             public void putSuggestionsToCache(String query, String[] suggestions, int flags) {
    514                 if (suggestions == null || TextUtils.isEmpty(query)) {
    515                     return;
    516                 }
    517                 mUnigramSuggestionsInfoCache.put(query, new SuggestionsParams(suggestions, flags));
    518             }
    519         }
    520 
    521         AndroidSpellCheckerSession(final AndroidSpellCheckerService service) {
    522             mService = service;
    523         }
    524 
    525         @Override
    526         public void onCreate() {
    527             final String localeString = getLocale();
    528             mDictionaryPool = mService.getDictionaryPool(localeString);
    529             mLocale = LocaleUtils.constructLocaleFromString(localeString);
    530             mScript = getScriptFromLocale(mLocale);
    531         }
    532 
    533         /*
    534          * Returns whether the code point is a letter that makes sense for the specified
    535          * locale for this spell checker.
    536          * The dictionaries supported by Latin IME are described in res/xml/spellchecker.xml
    537          * and is limited to EFIGS languages and Russian.
    538          * Hence at the moment this explicitly tests for Cyrillic characters or Latin characters
    539          * as appropriate, and explicitly excludes CJK, Arabic and Hebrew characters.
    540          */
    541         private static boolean isLetterCheckableByLanguage(final int codePoint,
    542                 final int script) {
    543             switch (script) {
    544             case SCRIPT_LATIN:
    545                 // Our supported latin script dictionaries (EFIGS) at the moment only include
    546                 // characters in the C0, C1, Latin Extended A and B, IPA extensions unicode
    547                 // blocks. As it happens, those are back-to-back in the code range 0x40 to 0x2AF,
    548                 // so the below is a very efficient way to test for it. As for the 0-0x3F, it's
    549                 // excluded from isLetter anyway.
    550                 return codePoint <= 0x2AF && Character.isLetter(codePoint);
    551             case SCRIPT_CYRILLIC:
    552                 // All Cyrillic characters are in the 400~52F block. There are some in the upper
    553                 // Unicode range, but they are archaic characters that are not used in modern
    554                 // russian and are not used by our dictionary.
    555                 return codePoint >= 0x400 && codePoint <= 0x52F && Character.isLetter(codePoint);
    556             default:
    557                 // Should never come here
    558                 throw new RuntimeException("Impossible value of script: " + script);
    559             }
    560         }
    561 
    562         /**
    563          * Finds out whether a particular string should be filtered out of spell checking.
    564          *
    565          * This will loosely match URLs, numbers, symbols. To avoid always underlining words that
    566          * we know we will never recognize, this accepts a script identifier that should be one
    567          * of the SCRIPT_* constants defined above, to rule out quickly characters from very
    568          * different languages.
    569          *
    570          * @param text the string to evaluate.
    571          * @param script the identifier for the script this spell checker recognizes
    572          * @return true if we should filter this text out, false otherwise
    573          */
    574         private static boolean shouldFilterOut(final String text, final int script) {
    575             if (TextUtils.isEmpty(text) || text.length() <= 1) return true;
    576 
    577             // TODO: check if an equivalent processing can't be done more quickly with a
    578             // compiled regexp.
    579             // Filter by first letter
    580             final int firstCodePoint = text.codePointAt(0);
    581             // Filter out words that don't start with a letter or an apostrophe
    582             if (!isLetterCheckableByLanguage(firstCodePoint, script)
    583                     && '\'' != firstCodePoint) return true;
    584 
    585             // Filter contents
    586             final int length = text.length();
    587             int letterCount = 0;
    588             for (int i = 0; i < length; i = text.offsetByCodePoints(i, 1)) {
    589                 final int codePoint = text.codePointAt(i);
    590                 // Any word containing a '@' is probably an e-mail address
    591                 // Any word containing a '/' is probably either an ad-hoc combination of two
    592                 // words or a URI - in either case we don't want to spell check that
    593                 if ('@' == codePoint || '/' == codePoint) return true;
    594                 if (isLetterCheckableByLanguage(codePoint, script)) ++letterCount;
    595             }
    596             // Guestimate heuristic: perform spell checking if at least 3/4 of the characters
    597             // in this word are letters
    598             return (letterCount * 4 < length * 3);
    599         }
    600 
    601         private SentenceSuggestionsInfo fixWronglyInvalidatedWordWithSingleQuote(
    602                 TextInfo ti, SentenceSuggestionsInfo ssi) {
    603             final String typedText = ti.getText();
    604             if (!typedText.contains(SINGLE_QUOTE)) {
    605                 return null;
    606             }
    607             final int N = ssi.getSuggestionsCount();
    608             final ArrayList<Integer> additionalOffsets = new ArrayList<Integer>();
    609             final ArrayList<Integer> additionalLengths = new ArrayList<Integer>();
    610             final ArrayList<SuggestionsInfo> additionalSuggestionsInfos =
    611                     new ArrayList<SuggestionsInfo>();
    612             for (int i = 0; i < N; ++i) {
    613                 final SuggestionsInfo si = ssi.getSuggestionsInfoAt(i);
    614                 final int flags = si.getSuggestionsAttributes();
    615                 if ((flags & SuggestionsInfo.RESULT_ATTR_IN_THE_DICTIONARY) == 0) {
    616                     continue;
    617                 }
    618                 final int offset = ssi.getOffsetAt(i);
    619                 final int length = ssi.getLengthAt(i);
    620                 final String subText = typedText.substring(offset, offset + length);
    621                 if (!subText.contains(SINGLE_QUOTE)) {
    622                     continue;
    623                 }
    624                 final String[] splitTexts = subText.split(SINGLE_QUOTE, -1);
    625                 if (splitTexts == null || splitTexts.length <= 1) {
    626                     continue;
    627                 }
    628                 final int splitNum = splitTexts.length;
    629                 for (int j = 0; j < splitNum; ++j) {
    630                     final String splitText = splitTexts[j];
    631                     if (TextUtils.isEmpty(splitText)) {
    632                         continue;
    633                     }
    634                     if (mSuggestionsCache.getSuggestionsFromCache(splitText) == null) {
    635                         continue;
    636                     }
    637                     final int newLength = splitText.length();
    638                     // Neither RESULT_ATTR_IN_THE_DICTIONARY nor RESULT_ATTR_LOOKS_LIKE_TYPO
    639                     final int newFlags = 0;
    640                     final SuggestionsInfo newSi = new SuggestionsInfo(newFlags, EMPTY_STRING_ARRAY);
    641                     newSi.setCookieAndSequence(si.getCookie(), si.getSequence());
    642                     if (DBG) {
    643                         Log.d(TAG, "Override and remove old span over: "
    644                                 + splitText + ", " + offset + "," + newLength);
    645                     }
    646                     additionalOffsets.add(offset);
    647                     additionalLengths.add(newLength);
    648                     additionalSuggestionsInfos.add(newSi);
    649                 }
    650             }
    651             final int additionalSize = additionalOffsets.size();
    652             if (additionalSize <= 0) {
    653                 return null;
    654             }
    655             final int suggestionsSize = N + additionalSize;
    656             final int[] newOffsets = new int[suggestionsSize];
    657             final int[] newLengths = new int[suggestionsSize];
    658             final SuggestionsInfo[] newSuggestionsInfos = new SuggestionsInfo[suggestionsSize];
    659             int i;
    660             for (i = 0; i < N; ++i) {
    661                 newOffsets[i] = ssi.getOffsetAt(i);
    662                 newLengths[i] = ssi.getLengthAt(i);
    663                 newSuggestionsInfos[i] = ssi.getSuggestionsInfoAt(i);
    664             }
    665             for (; i < suggestionsSize; ++i) {
    666                 newOffsets[i] = additionalOffsets.get(i - N);
    667                 newLengths[i] = additionalLengths.get(i - N);
    668                 newSuggestionsInfos[i] = additionalSuggestionsInfos.get(i - N);
    669             }
    670             return new SentenceSuggestionsInfo(newSuggestionsInfos, newOffsets, newLengths);
    671         }
    672 
    673         @Override
    674         public SentenceSuggestionsInfo[] onGetSentenceSuggestionsMultiple(
    675                 TextInfo[] textInfos, int suggestionsLimit) {
    676             final SentenceSuggestionsInfo[] retval = super.onGetSentenceSuggestionsMultiple(
    677                     textInfos, suggestionsLimit);
    678             if (retval == null || retval.length != textInfos.length) {
    679                 return retval;
    680             }
    681             for (int i = 0; i < retval.length; ++i) {
    682                 final SentenceSuggestionsInfo tempSsi =
    683                         fixWronglyInvalidatedWordWithSingleQuote(textInfos[i], retval[i]);
    684                 if (tempSsi != null) {
    685                     retval[i] = tempSsi;
    686                 }
    687             }
    688             return retval;
    689         }
    690 
    691         @Override
    692         public SuggestionsInfo[] onGetSuggestionsMultiple(TextInfo[] textInfos,
    693                 int suggestionsLimit, boolean sequentialWords) {
    694             final int length = textInfos.length;
    695             final SuggestionsInfo[] retval = new SuggestionsInfo[length];
    696             for (int i = 0; i < length; ++i) {
    697                 final String prevWord;
    698                 if (sequentialWords && i > 0) {
    699                     final String prevWordCandidate = textInfos[i - 1].getText();
    700                     // Note that an empty string would be used to indicate the initial word
    701                     // in the future.
    702                     prevWord = TextUtils.isEmpty(prevWordCandidate) ? null : prevWordCandidate;
    703                 } else {
    704                     prevWord = null;
    705                 }
    706                 retval[i] = onGetSuggestions(textInfos[i], prevWord, suggestionsLimit);
    707                 retval[i].setCookieAndSequence(
    708                         textInfos[i].getCookie(), textInfos[i].getSequence());
    709             }
    710             return retval;
    711         }
    712 
    713         // Note : this must be reentrant
    714         /**
    715          * Gets a list of suggestions for a specific string. This returns a list of possible
    716          * corrections for the text passed as an argument. It may split or group words, and
    717          * even perform grammatical analysis.
    718          */
    719         @Override
    720         public SuggestionsInfo onGetSuggestions(final TextInfo textInfo,
    721                 final int suggestionsLimit) {
    722             return onGetSuggestions(textInfo, null, suggestionsLimit);
    723         }
    724 
    725         private SuggestionsInfo onGetSuggestions(
    726                 final TextInfo textInfo, final String prevWord, final int suggestionsLimit) {
    727             try {
    728                 final String inText = textInfo.getText();
    729                 final SuggestionsParams cachedSuggestionsParams =
    730                         mSuggestionsCache.getSuggestionsFromCache(inText);
    731                 if (cachedSuggestionsParams != null) {
    732                     if (DBG) {
    733                         Log.d(TAG, "Cache hit: " + inText + ", " + cachedSuggestionsParams.mFlags);
    734                     }
    735                     return new SuggestionsInfo(
    736                             cachedSuggestionsParams.mFlags, cachedSuggestionsParams.mSuggestions);
    737                 }
    738 
    739                 if (shouldFilterOut(inText, mScript)) {
    740                     DictAndProximity dictInfo = null;
    741                     try {
    742                         dictInfo = mDictionaryPool.takeOrGetNull();
    743                         if (null == dictInfo) return getNotInDictEmptySuggestions();
    744                         return dictInfo.mDictionary.isValidWord(inText) ?
    745                                 getInDictEmptySuggestions() : getNotInDictEmptySuggestions();
    746                     } finally {
    747                         if (null != dictInfo) {
    748                             if (!mDictionaryPool.offer(dictInfo)) {
    749                                 Log.e(TAG, "Can't re-insert a dictionary into its pool");
    750                             }
    751                         }
    752                     }
    753                 }
    754                 final String text = inText.replaceAll(APOSTROPHE, SINGLE_QUOTE);
    755 
    756                 // TODO: Don't gather suggestions if the limit is <= 0 unless necessary
    757                 final SuggestionsGatherer suggestionsGatherer = new SuggestionsGatherer(text,
    758                         mService.mSuggestionThreshold, mService.mRecommendedThreshold,
    759                         suggestionsLimit);
    760                 final WordComposer composer = new WordComposer();
    761                 final int length = text.length();
    762                 for (int i = 0; i < length; i = text.offsetByCodePoints(i, 1)) {
    763                     final int codePoint = text.codePointAt(i);
    764                     // The getXYForCodePointAndScript method returns (Y << 16) + X
    765                     final int xy = SpellCheckerProximityInfo.getXYForCodePointAndScript(
    766                             codePoint, mScript);
    767                     if (SpellCheckerProximityInfo.NOT_A_COORDINATE_PAIR == xy) {
    768                         composer.add(codePoint, WordComposer.NOT_A_COORDINATE,
    769                                 WordComposer.NOT_A_COORDINATE, null);
    770                     } else {
    771                         composer.add(codePoint, xy & 0xFFFF, xy >> 16, null);
    772                     }
    773                 }
    774 
    775                 final int capitalizeType = getCapitalizationType(text);
    776                 boolean isInDict = true;
    777                 DictAndProximity dictInfo = null;
    778                 try {
    779                     dictInfo = mDictionaryPool.takeOrGetNull();
    780                     if (null == dictInfo) return getNotInDictEmptySuggestions();
    781                     dictInfo.mDictionary.getWords(composer, prevWord, suggestionsGatherer,
    782                             dictInfo.mProximityInfo);
    783                     isInDict = dictInfo.mDictionary.isValidWord(text);
    784                     if (!isInDict && CAPITALIZE_NONE != capitalizeType) {
    785                         // We want to test the word again if it's all caps or first caps only.
    786                         // If it's fully down, we already tested it, if it's mixed case, we don't
    787                         // want to test a lowercase version of it.
    788                         isInDict = dictInfo.mDictionary.isValidWord(text.toLowerCase(mLocale));
    789                     }
    790                 } finally {
    791                     if (null != dictInfo) {
    792                         if (!mDictionaryPool.offer(dictInfo)) {
    793                             Log.e(TAG, "Can't re-insert a dictionary into its pool");
    794                         }
    795                     }
    796                 }
    797 
    798                 final SuggestionsGatherer.Result result = suggestionsGatherer.getResults(
    799                         capitalizeType, mLocale);
    800 
    801                 if (DBG) {
    802                     Log.i(TAG, "Spell checking results for " + text + " with suggestion limit "
    803                             + suggestionsLimit);
    804                     Log.i(TAG, "IsInDict = " + isInDict);
    805                     Log.i(TAG, "LooksLikeTypo = " + (!isInDict));
    806                     Log.i(TAG, "HasRecommendedSuggestions = " + result.mHasRecommendedSuggestions);
    807                     if (null != result.mSuggestions) {
    808                         for (String suggestion : result.mSuggestions) {
    809                             Log.i(TAG, suggestion);
    810                         }
    811                     }
    812                 }
    813 
    814                 final int flags =
    815                         (isInDict ? SuggestionsInfo.RESULT_ATTR_IN_THE_DICTIONARY
    816                                 : SuggestionsInfo.RESULT_ATTR_LOOKS_LIKE_TYPO)
    817                         | (result.mHasRecommendedSuggestions
    818                                 ? SuggestionsInfoCompatUtils
    819                                         .getValueOf_RESULT_ATTR_HAS_RECOMMENDED_SUGGESTIONS()
    820                                 : 0);
    821                 final SuggestionsInfo retval = new SuggestionsInfo(flags, result.mSuggestions);
    822                 mSuggestionsCache.putSuggestionsToCache(text, result.mSuggestions, flags);
    823                 return retval;
    824             } catch (RuntimeException e) {
    825                 // Don't kill the keyboard if there is a bug in the spell checker
    826                 if (DBG) {
    827                     throw e;
    828                 } else {
    829                     Log.e(TAG, "Exception while spellcheking: " + e);
    830                     return getNotInDictEmptySuggestions();
    831                 }
    832             }
    833         }
    834     }
    835 }
    836