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