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");
      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.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.util.Log;
     24 import android.view.textservice.SuggestionsInfo;
     25 
     26 import com.android.inputmethod.keyboard.KeyboardLayoutSet;
     27 import com.android.inputmethod.latin.BinaryDictionary;
     28 import com.android.inputmethod.latin.CollectionUtils;
     29 import com.android.inputmethod.latin.ContactsBinaryDictionary;
     30 import com.android.inputmethod.latin.Dictionary;
     31 import com.android.inputmethod.latin.DictionaryCollection;
     32 import com.android.inputmethod.latin.DictionaryFactory;
     33 import com.android.inputmethod.latin.LocaleUtils;
     34 import com.android.inputmethod.latin.R;
     35 import com.android.inputmethod.latin.StringUtils;
     36 import com.android.inputmethod.latin.SynchronouslyLoadedContactsBinaryDictionary;
     37 import com.android.inputmethod.latin.SynchronouslyLoadedUserBinaryDictionary;
     38 import com.android.inputmethod.latin.UserBinaryDictionary;
     39 
     40 import java.lang.ref.WeakReference;
     41 import java.util.ArrayList;
     42 import java.util.Arrays;
     43 import java.util.Collections;
     44 import java.util.HashSet;
     45 import java.util.Iterator;
     46 import java.util.Locale;
     47 import java.util.Map;
     48 import java.util.TreeMap;
     49 
     50 /**
     51  * Service for spell checking, using LatinIME's dictionaries and mechanisms.
     52  */
     53 public final class AndroidSpellCheckerService extends SpellCheckerService
     54         implements SharedPreferences.OnSharedPreferenceChangeListener {
     55     private static final String TAG = AndroidSpellCheckerService.class.getSimpleName();
     56     private static final boolean DBG = false;
     57     private static final int POOL_SIZE = 2;
     58 
     59     public static final String PREF_USE_CONTACTS_KEY = "pref_spellcheck_use_contacts";
     60 
     61     private final static String[] EMPTY_STRING_ARRAY = new String[0];
     62     private Map<String, DictionaryPool> mDictionaryPools = CollectionUtils.newSynchronizedTreeMap();
     63     private Map<String, UserBinaryDictionary> mUserDictionaries =
     64             CollectionUtils.newSynchronizedTreeMap();
     65     private ContactsBinaryDictionary mContactsDictionary;
     66 
     67     // The threshold for a suggestion to be considered "recommended".
     68     private float mRecommendedThreshold;
     69     // Whether to use the contacts dictionary
     70     private boolean mUseContactsDictionary;
     71     private final Object mUseContactsLock = new Object();
     72 
     73     private final HashSet<WeakReference<DictionaryCollection>> mDictionaryCollectionsList =
     74             CollectionUtils.newHashSet();
     75 
     76     public static final int SCRIPT_LATIN = 0;
     77     public static final int SCRIPT_CYRILLIC = 1;
     78     public static final int SCRIPT_GREEK = 2;
     79     public static final String SINGLE_QUOTE = "\u0027";
     80     public static final String APOSTROPHE = "\u2019";
     81     private static final TreeMap<String, Integer> mLanguageToScript;
     82     static {
     83         // List of the supported languages and their associated script. We won't check
     84         // words written in another script than the selected script, because we know we
     85         // don't have those in our dictionary so we will underline everything and we
     86         // will never have any suggestions, so it makes no sense checking them, and this
     87         // is done in {@link #shouldFilterOut}. Also, the script is used to choose which
     88         // proximity to pass to the dictionary descent algorithm.
     89         // IMPORTANT: this only contains languages - do not write countries in there.
     90         // Only the language is searched from the map.
     91         mLanguageToScript = CollectionUtils.newTreeMap();
     92         mLanguageToScript.put("cs", SCRIPT_LATIN);
     93         mLanguageToScript.put("da", SCRIPT_LATIN);
     94         mLanguageToScript.put("de", SCRIPT_LATIN);
     95         mLanguageToScript.put("el", SCRIPT_GREEK);
     96         mLanguageToScript.put("en", SCRIPT_LATIN);
     97         mLanguageToScript.put("es", SCRIPT_LATIN);
     98         mLanguageToScript.put("fi", SCRIPT_LATIN);
     99         mLanguageToScript.put("fr", SCRIPT_LATIN);
    100         mLanguageToScript.put("hr", SCRIPT_LATIN);
    101         mLanguageToScript.put("it", SCRIPT_LATIN);
    102         mLanguageToScript.put("lt", SCRIPT_LATIN);
    103         mLanguageToScript.put("lv", SCRIPT_LATIN);
    104         mLanguageToScript.put("nb", SCRIPT_LATIN);
    105         mLanguageToScript.put("nl", SCRIPT_LATIN);
    106         mLanguageToScript.put("pt", SCRIPT_LATIN);
    107         mLanguageToScript.put("sl", SCRIPT_LATIN);
    108         mLanguageToScript.put("ru", SCRIPT_CYRILLIC);
    109     }
    110 
    111     @Override public void onCreate() {
    112         super.onCreate();
    113         mRecommendedThreshold =
    114                 Float.parseFloat(getString(R.string.spellchecker_recommended_threshold_value));
    115         final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
    116         prefs.registerOnSharedPreferenceChangeListener(this);
    117         onSharedPreferenceChanged(prefs, PREF_USE_CONTACTS_KEY);
    118     }
    119 
    120     public static int getScriptFromLocale(final Locale locale) {
    121         final Integer script = mLanguageToScript.get(locale.getLanguage());
    122         if (null == script) {
    123             throw new RuntimeException("We have been called with an unsupported language: \""
    124                     + locale.getLanguage() + "\". Framework bug?");
    125         }
    126         return script;
    127     }
    128 
    129     private static String getKeyboardLayoutNameForScript(final int script) {
    130         switch (script) {
    131         case AndroidSpellCheckerService.SCRIPT_LATIN:
    132             return "qwerty";
    133         case AndroidSpellCheckerService.SCRIPT_CYRILLIC:
    134             return "east_slavic";
    135         case AndroidSpellCheckerService.SCRIPT_GREEK:
    136             return "greek";
    137         default:
    138             throw new RuntimeException("Wrong script supplied: " + script);
    139         }
    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             // TODO: use the right locale for each session
    158             mContactsDictionary =
    159                     new SynchronouslyLoadedContactsBinaryDictionary(this, Locale.getDefault());
    160         }
    161         final Iterator<WeakReference<DictionaryCollection>> iterator =
    162                 mDictionaryCollectionsList.iterator();
    163         while (iterator.hasNext()) {
    164             final WeakReference<DictionaryCollection> dictRef = iterator.next();
    165             final DictionaryCollection dict = dictRef.get();
    166             if (null == dict) {
    167                 iterator.remove();
    168             } else {
    169                 dict.addDictionary(mContactsDictionary);
    170             }
    171         }
    172     }
    173 
    174     private void stopUsingContactsDictionaryLocked() {
    175         if (null == mContactsDictionary) return;
    176         final Dictionary contactsDict = mContactsDictionary;
    177         // TODO: revert to the concrete type when USE_BINARY_CONTACTS_DICTIONARY is no longer needed
    178         mContactsDictionary = null;
    179         final Iterator<WeakReference<DictionaryCollection>> iterator =
    180                 mDictionaryCollectionsList.iterator();
    181         while (iterator.hasNext()) {
    182             final WeakReference<DictionaryCollection> dictRef = iterator.next();
    183             final DictionaryCollection dict = dictRef.get();
    184             if (null == dict) {
    185                 iterator.remove();
    186             } else {
    187                 dict.removeDictionary(contactsDict);
    188             }
    189         }
    190         contactsDict.close();
    191     }
    192 
    193     @Override
    194     public Session createSession() {
    195         // Should not refer to AndroidSpellCheckerSession directly considering
    196         // that AndroidSpellCheckerSession may be overlaid.
    197         return AndroidSpellCheckerSessionFactory.newInstance(this);
    198     }
    199 
    200     public static SuggestionsInfo getNotInDictEmptySuggestions() {
    201         return new SuggestionsInfo(0, EMPTY_STRING_ARRAY);
    202     }
    203 
    204     public static SuggestionsInfo getInDictEmptySuggestions() {
    205         return new SuggestionsInfo(SuggestionsInfo.RESULT_ATTR_IN_THE_DICTIONARY,
    206                 EMPTY_STRING_ARRAY);
    207     }
    208 
    209     public SuggestionsGatherer newSuggestionsGatherer(final String text, int maxLength) {
    210         return new SuggestionsGatherer(text, mRecommendedThreshold, maxLength);
    211     }
    212 
    213     // TODO: remove this class and replace it by storage local to the session.
    214     public static final class SuggestionsGatherer {
    215         public static final class Result {
    216             public final String[] mSuggestions;
    217             public final boolean mHasRecommendedSuggestions;
    218             public Result(final String[] gatheredSuggestions,
    219                     final boolean hasRecommendedSuggestions) {
    220                 mSuggestions = gatheredSuggestions;
    221                 mHasRecommendedSuggestions = hasRecommendedSuggestions;
    222             }
    223         }
    224 
    225         private final ArrayList<String> mSuggestions;
    226         private final int[] mScores;
    227         private final String mOriginalText;
    228         private final float mRecommendedThreshold;
    229         private final int mMaxLength;
    230         private int mLength = 0;
    231 
    232         // The two following attributes are only ever filled if the requested max length
    233         // is 0 (or less, which is treated the same).
    234         private String mBestSuggestion = null;
    235         private int mBestScore = Integer.MIN_VALUE; // As small as possible
    236 
    237         SuggestionsGatherer(final String originalText, final float recommendedThreshold,
    238                 final int maxLength) {
    239             mOriginalText = originalText;
    240             mRecommendedThreshold = recommendedThreshold;
    241             mMaxLength = maxLength;
    242             mSuggestions = CollectionUtils.newArrayList(maxLength + 1);
    243             mScores = new int[mMaxLength];
    244         }
    245 
    246         synchronized public boolean addWord(char[] word, int[] spaceIndices, int wordOffset,
    247                 int wordLength, int score) {
    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             final String wordString = new String(word, wordOffset, wordLength);
    277             if (mLength < mMaxLength) {
    278                 final int copyLen = mLength - insertIndex;
    279                 ++mLength;
    280                 System.arraycopy(mScores, insertIndex, mScores, insertIndex + 1, copyLen);
    281                 mSuggestions.add(insertIndex, wordString);
    282             } else {
    283                 System.arraycopy(mScores, 1, mScores, 0, insertIndex);
    284                 mSuggestions.add(insertIndex, wordString);
    285                 mSuggestions.remove(0);
    286             }
    287             mScores[insertIndex] = score;
    288 
    289             return true;
    290         }
    291 
    292         public Result getResults(final int capitalizeType, final Locale locale) {
    293             final String[] gatheredSuggestions;
    294             final boolean hasRecommendedSuggestions;
    295             if (0 == mLength) {
    296                 // TODO: the comment below describes what is intended, but in the practice
    297                 // mBestSuggestion is only ever set to null so it doesn't work. Fix this.
    298                 // Either we found no suggestions, or we found some BUT the max length was 0.
    299                 // If we found some mBestSuggestion will not be null. If it is null, then
    300                 // we found none, regardless of the max length.
    301                 if (null == mBestSuggestion) {
    302                     gatheredSuggestions = null;
    303                     hasRecommendedSuggestions = false;
    304                 } else {
    305                     gatheredSuggestions = EMPTY_STRING_ARRAY;
    306                     final float normalizedScore = BinaryDictionary.calcNormalizedScore(
    307                             mOriginalText, mBestSuggestion, mBestScore);
    308                     hasRecommendedSuggestions = (normalizedScore > mRecommendedThreshold);
    309                 }
    310             } else {
    311                 if (DBG) {
    312                     if (mLength != mSuggestions.size()) {
    313                         Log.e(TAG, "Suggestion size is not the same as stored mLength");
    314                     }
    315                     for (int i = mLength - 1; i >= 0; --i) {
    316                         Log.i(TAG, "" + mScores[i] + " " + mSuggestions.get(i));
    317                     }
    318                 }
    319                 Collections.reverse(mSuggestions);
    320                 StringUtils.removeDupes(mSuggestions);
    321                 if (StringUtils.CAPITALIZE_ALL == capitalizeType) {
    322                     for (int i = 0; i < mSuggestions.size(); ++i) {
    323                         // get(i) returns a CharSequence which is actually a String so .toString()
    324                         // should return the same object.
    325                         mSuggestions.set(i, mSuggestions.get(i).toString().toUpperCase(locale));
    326                     }
    327                 } else if (StringUtils.CAPITALIZE_FIRST == capitalizeType) {
    328                     for (int i = 0; i < mSuggestions.size(); ++i) {
    329                         // Likewise
    330                         mSuggestions.set(i, StringUtils.capitalizeFirstCodePoint(
    331                                 mSuggestions.get(i).toString(), locale));
    332                     }
    333                 }
    334                 // This returns a String[], while toArray() returns an Object[] which cannot be cast
    335                 // into a String[].
    336                 gatheredSuggestions = mSuggestions.toArray(EMPTY_STRING_ARRAY);
    337 
    338                 final int bestScore = mScores[mLength - 1];
    339                 final String bestSuggestion = mSuggestions.get(0);
    340                 final float normalizedScore =
    341                         BinaryDictionary.calcNormalizedScore(
    342                                 mOriginalText, bestSuggestion.toString(), bestScore);
    343                 hasRecommendedSuggestions = (normalizedScore > mRecommendedThreshold);
    344                 if (DBG) {
    345                     Log.i(TAG, "Best suggestion : " + bestSuggestion + ", score " + bestScore);
    346                     Log.i(TAG, "Normalized score = " + normalizedScore
    347                             + " (threshold " + mRecommendedThreshold
    348                             + ") => hasRecommendedSuggestions = " + hasRecommendedSuggestions);
    349                 }
    350             }
    351             return new Result(gatheredSuggestions, hasRecommendedSuggestions);
    352         }
    353     }
    354 
    355     @Override
    356     public boolean onUnbind(final Intent intent) {
    357         closeAllDictionaries();
    358         return false;
    359     }
    360 
    361     private void closeAllDictionaries() {
    362         final Map<String, DictionaryPool> oldPools = mDictionaryPools;
    363         mDictionaryPools = CollectionUtils.newSynchronizedTreeMap();
    364         final Map<String, UserBinaryDictionary> oldUserDictionaries = mUserDictionaries;
    365         mUserDictionaries = CollectionUtils.newSynchronizedTreeMap();
    366         new Thread("spellchecker_close_dicts") {
    367             @Override
    368             public void run() {
    369                 for (DictionaryPool pool : oldPools.values()) {
    370                     pool.close();
    371                 }
    372                 for (Dictionary dict : oldUserDictionaries.values()) {
    373                     dict.close();
    374                 }
    375                 synchronized (mUseContactsLock) {
    376                     if (null != mContactsDictionary) {
    377                         // The synchronously loaded contacts dictionary should have been in one
    378                         // or several pools, but it is shielded against multiple closing and it's
    379                         // safe to call it several times.
    380                         final ContactsBinaryDictionary dictToClose = mContactsDictionary;
    381                         // TODO: revert to the concrete type when USE_BINARY_CONTACTS_DICTIONARY
    382                         // is no longer needed
    383                         mContactsDictionary = null;
    384                         dictToClose.close();
    385                     }
    386                 }
    387             }
    388         }.start();
    389     }
    390 
    391     public DictionaryPool getDictionaryPool(final String locale) {
    392         DictionaryPool pool = mDictionaryPools.get(locale);
    393         if (null == pool) {
    394             final Locale localeObject = LocaleUtils.constructLocaleFromString(locale);
    395             pool = new DictionaryPool(POOL_SIZE, this, localeObject);
    396             mDictionaryPools.put(locale, pool);
    397         }
    398         return pool;
    399     }
    400 
    401     public DictAndKeyboard createDictAndKeyboard(final Locale locale) {
    402         final int script = getScriptFromLocale(locale);
    403         final String keyboardLayoutName = getKeyboardLayoutNameForScript(script);
    404         final KeyboardLayoutSet keyboardLayoutSet =
    405                 KeyboardLayoutSet.createKeyboardSetForSpellChecker(this, locale.toString(),
    406                         keyboardLayoutName);
    407 
    408         final DictionaryCollection dictionaryCollection =
    409                 DictionaryFactory.createMainDictionaryFromManager(this, locale,
    410                         true /* useFullEditDistance */);
    411         final String localeStr = locale.toString();
    412         UserBinaryDictionary userDictionary = mUserDictionaries.get(localeStr);
    413         if (null == userDictionary) {
    414             userDictionary = new SynchronouslyLoadedUserBinaryDictionary(this, localeStr, true);
    415             mUserDictionaries.put(localeStr, userDictionary);
    416         }
    417         dictionaryCollection.addDictionary(userDictionary);
    418         synchronized (mUseContactsLock) {
    419             if (mUseContactsDictionary) {
    420                 if (null == mContactsDictionary) {
    421                     // TODO: use the right locale. We can't do it right now because the
    422                     // spell checker is reusing the contacts dictionary across sessions
    423                     // without regard for their locale, so we need to fix that first.
    424                     mContactsDictionary = new SynchronouslyLoadedContactsBinaryDictionary(this,
    425                             Locale.getDefault());
    426                 }
    427             }
    428             dictionaryCollection.addDictionary(mContactsDictionary);
    429             mDictionaryCollectionsList.add(
    430                     new WeakReference<DictionaryCollection>(dictionaryCollection));
    431         }
    432         return new DictAndKeyboard(dictionaryCollection, keyboardLayoutSet);
    433     }
    434 }
    435