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.Context;
     20 import android.content.Intent;
     21 import android.content.SharedPreferences;
     22 import android.preference.PreferenceManager;
     23 import android.service.textservice.SpellCheckerService;
     24 import android.text.InputType;
     25 import android.util.Log;
     26 import android.util.LruCache;
     27 import android.view.inputmethod.EditorInfo;
     28 import android.view.inputmethod.InputMethodSubtype;
     29 import android.view.textservice.SuggestionsInfo;
     30 
     31 import com.android.inputmethod.keyboard.Keyboard;
     32 import com.android.inputmethod.keyboard.KeyboardId;
     33 import com.android.inputmethod.keyboard.KeyboardLayoutSet;
     34 import com.android.inputmethod.keyboard.ProximityInfo;
     35 import com.android.inputmethod.latin.ContactsBinaryDictionary;
     36 import com.android.inputmethod.latin.Dictionary;
     37 import com.android.inputmethod.latin.DictionaryCollection;
     38 import com.android.inputmethod.latin.DictionaryFacilitator;
     39 import com.android.inputmethod.latin.DictionaryFactory;
     40 import com.android.inputmethod.latin.PrevWordsInfo;
     41 import com.android.inputmethod.latin.R;
     42 import com.android.inputmethod.latin.SuggestedWords.SuggestedWordInfo;
     43 import com.android.inputmethod.latin.settings.SettingsValuesForSuggestion;
     44 import com.android.inputmethod.latin.UserBinaryDictionary;
     45 import com.android.inputmethod.latin.utils.AdditionalSubtypeUtils;
     46 import com.android.inputmethod.latin.utils.BinaryDictionaryUtils;
     47 import com.android.inputmethod.latin.utils.CollectionUtils;
     48 import com.android.inputmethod.latin.utils.LocaleUtils;
     49 import com.android.inputmethod.latin.utils.ScriptUtils;
     50 import com.android.inputmethod.latin.utils.StringUtils;
     51 import com.android.inputmethod.latin.utils.SuggestionResults;
     52 import com.android.inputmethod.latin.WordComposer;
     53 
     54 import java.lang.ref.WeakReference;
     55 import java.util.ArrayList;
     56 import java.util.Arrays;
     57 import java.util.Collections;
     58 import java.util.HashMap;
     59 import java.util.HashSet;
     60 import java.util.Iterator;
     61 import java.util.Locale;
     62 import java.util.Map;
     63 import java.util.TreeMap;
     64 import java.util.concurrent.ConcurrentHashMap;
     65 import java.util.concurrent.ConcurrentLinkedQueue;
     66 import java.util.concurrent.Semaphore;
     67 import java.util.concurrent.TimeUnit;
     68 
     69 /**
     70  * Service for spell checking, using LatinIME's dictionaries and mechanisms.
     71  */
     72 public final class AndroidSpellCheckerService extends SpellCheckerService
     73         implements SharedPreferences.OnSharedPreferenceChangeListener {
     74     private static final String TAG = AndroidSpellCheckerService.class.getSimpleName();
     75     private static final boolean DBG = false;
     76 
     77     public static final String PREF_USE_CONTACTS_KEY = "pref_spellcheck_use_contacts";
     78 
     79     private static final int SPELLCHECKER_DUMMY_KEYBOARD_WIDTH = 480;
     80     private static final int SPELLCHECKER_DUMMY_KEYBOARD_HEIGHT = 368;
     81 
     82     private static final String DICTIONARY_NAME_PREFIX = "spellcheck_";
     83     private static final int WAIT_FOR_LOADING_MAIN_DICT_IN_MILLISECONDS = 1000;
     84     private static final int MAX_RETRY_COUNT_FOR_WAITING_FOR_LOADING_DICT = 5;
     85 
     86     private static final String[] EMPTY_STRING_ARRAY = new String[0];
     87 
     88     private final HashSet<Locale> mCachedLocales = new HashSet<>();
     89 
     90     private final int MAX_NUM_OF_THREADS_READ_DICTIONARY = 2;
     91     private final Semaphore mSemaphore = new Semaphore(MAX_NUM_OF_THREADS_READ_DICTIONARY,
     92             true /* fair */);
     93     // TODO: Make each spell checker session has its own session id.
     94     private final ConcurrentLinkedQueue<Integer> mSessionIdPool = new ConcurrentLinkedQueue<>();
     95 
     96     private static class DictionaryFacilitatorLruCache extends
     97             LruCache<Locale, DictionaryFacilitator> {
     98         private final HashSet<Locale> mCachedLocales;
     99         public DictionaryFacilitatorLruCache(final HashSet<Locale> cachedLocales, int maxSize) {
    100             super(maxSize);
    101             mCachedLocales = cachedLocales;
    102         }
    103 
    104         @Override
    105         protected void entryRemoved(boolean evicted, Locale key,
    106                 DictionaryFacilitator oldValue, DictionaryFacilitator newValue) {
    107             if (oldValue != null && oldValue != newValue) {
    108                 oldValue.closeDictionaries();
    109             }
    110             if (key != null && newValue == null) {
    111                 // Remove locale from the cache when the dictionary facilitator for the locale is
    112                 // evicted and new facilitator is not set for the locale.
    113                 mCachedLocales.remove(key);
    114                 if (size() >= maxSize()) {
    115                     Log.w(TAG, "DictionaryFacilitator for " + key.toString()
    116                             + " has been evicted due to cache size limit."
    117                             + " size: " + size() + ", maxSize: " + maxSize());
    118                 }
    119             }
    120         }
    121     }
    122 
    123     private static final int MAX_DICTIONARY_FACILITATOR_COUNT = 3;
    124     private final LruCache<Locale, DictionaryFacilitator> mDictionaryFacilitatorCache =
    125             new DictionaryFacilitatorLruCache(mCachedLocales, MAX_DICTIONARY_FACILITATOR_COUNT);
    126     private final ConcurrentHashMap<Locale, Keyboard> mKeyboardCache = new ConcurrentHashMap<>();
    127 
    128     // The threshold for a suggestion to be considered "recommended".
    129     private float mRecommendedThreshold;
    130     // Whether to use the contacts dictionary
    131     private boolean mUseContactsDictionary;
    132     // TODO: make a spell checker option to block offensive words or not
    133     private final SettingsValuesForSuggestion mSettingsValuesForSuggestion =
    134             new SettingsValuesForSuggestion(true /* blockPotentiallyOffensive */,
    135                     true /* spaceAwareGestureEnabled */,
    136                     null /* additionalFeaturesSettingValues */);
    137     private final Object mDictionaryLock = new Object();
    138 
    139     public static final String SINGLE_QUOTE = "\u0027";
    140     public static final String APOSTROPHE = "\u2019";
    141 
    142     public AndroidSpellCheckerService() {
    143         super();
    144         for (int i = 0; i < MAX_NUM_OF_THREADS_READ_DICTIONARY; i++) {
    145             mSessionIdPool.add(i);
    146         }
    147     }
    148 
    149     @Override public void onCreate() {
    150         super.onCreate();
    151         mRecommendedThreshold =
    152                 Float.parseFloat(getString(R.string.spellchecker_recommended_threshold_value));
    153         final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
    154         prefs.registerOnSharedPreferenceChangeListener(this);
    155         onSharedPreferenceChanged(prefs, PREF_USE_CONTACTS_KEY);
    156     }
    157 
    158     public float getRecommendedThreshold() {
    159         return mRecommendedThreshold;
    160     }
    161 
    162     private static String getKeyboardLayoutNameForScript(final int script) {
    163         switch (script) {
    164         case ScriptUtils.SCRIPT_LATIN:
    165             return "qwerty";
    166         case ScriptUtils.SCRIPT_CYRILLIC:
    167             return "east_slavic";
    168         case ScriptUtils.SCRIPT_GREEK:
    169             return "greek";
    170         default:
    171             throw new RuntimeException("Wrong script supplied: " + script);
    172         }
    173     }
    174 
    175     @Override
    176     public void onSharedPreferenceChanged(final SharedPreferences prefs, final String key) {
    177         if (!PREF_USE_CONTACTS_KEY.equals(key)) return;
    178             final boolean useContactsDictionary = prefs.getBoolean(PREF_USE_CONTACTS_KEY, true);
    179             if (useContactsDictionary != mUseContactsDictionary) {
    180                 mSemaphore.acquireUninterruptibly(MAX_NUM_OF_THREADS_READ_DICTIONARY);
    181                 try {
    182                     mUseContactsDictionary = useContactsDictionary;
    183                     for (final Locale locale : mCachedLocales) {
    184                         final DictionaryFacilitator dictionaryFacilitator =
    185                                 mDictionaryFacilitatorCache.get(locale);
    186                         resetDictionariesForLocale(this /* context  */,
    187                                 dictionaryFacilitator, locale, mUseContactsDictionary);
    188                     }
    189                 } finally {
    190                     mSemaphore.release(MAX_NUM_OF_THREADS_READ_DICTIONARY);
    191                 }
    192             }
    193     }
    194 
    195     @Override
    196     public Session createSession() {
    197         // Should not refer to AndroidSpellCheckerSession directly considering
    198         // that AndroidSpellCheckerSession may be overlaid.
    199         return AndroidSpellCheckerSessionFactory.newInstance(this);
    200     }
    201 
    202     /**
    203      * Returns an empty SuggestionsInfo with flags signaling the word is not in the dictionary.
    204      * @param reportAsTypo whether this should include the flag LOOKS_LIKE_TYPO, for red underline.
    205      * @return the empty SuggestionsInfo with the appropriate flags set.
    206      */
    207     public static SuggestionsInfo getNotInDictEmptySuggestions(final boolean reportAsTypo) {
    208         return new SuggestionsInfo(reportAsTypo ? SuggestionsInfo.RESULT_ATTR_LOOKS_LIKE_TYPO : 0,
    209                 EMPTY_STRING_ARRAY);
    210     }
    211 
    212     /**
    213      * Returns an empty suggestionInfo with flags signaling the word is in the dictionary.
    214      * @return the empty SuggestionsInfo with the appropriate flags set.
    215      */
    216     public static SuggestionsInfo getInDictEmptySuggestions() {
    217         return new SuggestionsInfo(SuggestionsInfo.RESULT_ATTR_IN_THE_DICTIONARY,
    218                 EMPTY_STRING_ARRAY);
    219     }
    220 
    221     public boolean isValidWord(final Locale locale, final String word) {
    222         mSemaphore.acquireUninterruptibly();
    223         try {
    224             DictionaryFacilitator dictionaryFacilitatorForLocale =
    225                     getDictionaryFacilitatorForLocaleLocked(locale);
    226             return dictionaryFacilitatorForLocale.isValidWord(word, false /* igroreCase */);
    227         } finally {
    228             mSemaphore.release();
    229         }
    230     }
    231 
    232     public SuggestionResults getSuggestionResults(final Locale locale, final WordComposer composer,
    233             final PrevWordsInfo prevWordsInfo, final ProximityInfo proximityInfo) {
    234         Integer sessionId = null;
    235         mSemaphore.acquireUninterruptibly();
    236         try {
    237             sessionId = mSessionIdPool.poll();
    238             DictionaryFacilitator dictionaryFacilitatorForLocale =
    239                     getDictionaryFacilitatorForLocaleLocked(locale);
    240             return dictionaryFacilitatorForLocale.getSuggestionResults(composer, prevWordsInfo,
    241                     proximityInfo, mSettingsValuesForSuggestion, sessionId);
    242         } finally {
    243             if (sessionId != null) {
    244                 mSessionIdPool.add(sessionId);
    245             }
    246             mSemaphore.release();
    247         }
    248     }
    249 
    250     public boolean hasMainDictionaryForLocale(final Locale locale) {
    251         mSemaphore.acquireUninterruptibly();
    252         try {
    253             final DictionaryFacilitator dictionaryFacilitator =
    254                     getDictionaryFacilitatorForLocaleLocked(locale);
    255             return dictionaryFacilitator.hasInitializedMainDictionary();
    256         } finally {
    257             mSemaphore.release();
    258         }
    259     }
    260 
    261     private DictionaryFacilitator getDictionaryFacilitatorForLocaleLocked(final Locale locale) {
    262         DictionaryFacilitator dictionaryFacilitatorForLocale =
    263                 mDictionaryFacilitatorCache.get(locale);
    264         if (dictionaryFacilitatorForLocale == null) {
    265             dictionaryFacilitatorForLocale = new DictionaryFacilitator();
    266             mDictionaryFacilitatorCache.put(locale, dictionaryFacilitatorForLocale);
    267             mCachedLocales.add(locale);
    268             resetDictionariesForLocale(this /* context */, dictionaryFacilitatorForLocale,
    269                     locale, mUseContactsDictionary);
    270         }
    271         return dictionaryFacilitatorForLocale;
    272     }
    273 
    274     private static void resetDictionariesForLocale(final Context context,
    275             final DictionaryFacilitator dictionaryFacilitator, final Locale locale,
    276             final boolean useContactsDictionary) {
    277         dictionaryFacilitator.resetDictionariesWithDictNamePrefix(context, locale,
    278                 useContactsDictionary, false /* usePersonalizedDicts */,
    279                 false /* forceReloadMainDictionary */, null /* listener */,
    280                 DICTIONARY_NAME_PREFIX);
    281         for (int i = 0; i < MAX_RETRY_COUNT_FOR_WAITING_FOR_LOADING_DICT; i++) {
    282             try {
    283                 dictionaryFacilitator.waitForLoadingMainDictionary(
    284                         WAIT_FOR_LOADING_MAIN_DICT_IN_MILLISECONDS, TimeUnit.MILLISECONDS);
    285                 return;
    286             } catch (final InterruptedException e) {
    287                 Log.i(TAG, "Interrupted during waiting for loading main dictionary.", e);
    288                 if (i < MAX_RETRY_COUNT_FOR_WAITING_FOR_LOADING_DICT - 1) {
    289                     Log.i(TAG, "Retry", e);
    290                 } else {
    291                     Log.w(TAG, "Give up retrying. Retried "
    292                             + MAX_RETRY_COUNT_FOR_WAITING_FOR_LOADING_DICT + " times.", e);
    293                 }
    294             }
    295         }
    296     }
    297 
    298     @Override
    299     public boolean onUnbind(final Intent intent) {
    300         mSemaphore.acquireUninterruptibly(MAX_NUM_OF_THREADS_READ_DICTIONARY);
    301         try {
    302             mDictionaryFacilitatorCache.evictAll();
    303             mCachedLocales.clear();
    304         } finally {
    305             mSemaphore.release(MAX_NUM_OF_THREADS_READ_DICTIONARY);
    306         }
    307         mKeyboardCache.clear();
    308         return false;
    309     }
    310 
    311     public Keyboard getKeyboardForLocale(final Locale locale) {
    312         Keyboard keyboard = mKeyboardCache.get(locale);
    313         if (keyboard == null) {
    314             keyboard = createKeyboardForLocale(locale);
    315             if (keyboard != null) {
    316                 mKeyboardCache.put(locale, keyboard);
    317             }
    318         }
    319         return keyboard;
    320     }
    321 
    322     private Keyboard createKeyboardForLocale(final Locale locale) {
    323         final int script = ScriptUtils.getScriptFromSpellCheckerLocale(locale);
    324         final String keyboardLayoutName = getKeyboardLayoutNameForScript(script);
    325         final InputMethodSubtype subtype = AdditionalSubtypeUtils.createDummyAdditionalSubtype(
    326                 locale.toString(), keyboardLayoutName);
    327         final KeyboardLayoutSet keyboardLayoutSet = createKeyboardSetForSpellChecker(subtype);
    328         return keyboardLayoutSet.getKeyboard(KeyboardId.ELEMENT_ALPHABET);
    329     }
    330 
    331     private KeyboardLayoutSet createKeyboardSetForSpellChecker(final InputMethodSubtype subtype) {
    332         final EditorInfo editorInfo = new EditorInfo();
    333         editorInfo.inputType = InputType.TYPE_CLASS_TEXT;
    334         final KeyboardLayoutSet.Builder builder = new KeyboardLayoutSet.Builder(this, editorInfo);
    335         builder.setKeyboardGeometry(
    336                 SPELLCHECKER_DUMMY_KEYBOARD_WIDTH, SPELLCHECKER_DUMMY_KEYBOARD_HEIGHT);
    337         builder.setSubtype(subtype);
    338         builder.setIsSpellChecker(true /* isSpellChecker */);
    339         builder.disableTouchPositionCorrectionData();
    340         return builder.build();
    341     }
    342 }
    343