AndroidSpellCheckerService.java revision 86dee2295dccd9af3c58e946bc8f2b62736c0260
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 17package com.android.inputmethod.latin.spellcheck; 18 19import android.content.Context; 20import android.content.Intent; 21import android.content.SharedPreferences; 22import android.preference.PreferenceManager; 23import android.service.textservice.SpellCheckerService; 24import android.text.InputType; 25import android.util.Log; 26import android.util.LruCache; 27import android.view.inputmethod.EditorInfo; 28import android.view.inputmethod.InputMethodSubtype; 29import android.view.textservice.SuggestionsInfo; 30 31import com.android.inputmethod.keyboard.Keyboard; 32import com.android.inputmethod.keyboard.KeyboardId; 33import com.android.inputmethod.keyboard.KeyboardLayoutSet; 34import com.android.inputmethod.keyboard.ProximityInfo; 35import com.android.inputmethod.latin.ContactsBinaryDictionary; 36import com.android.inputmethod.latin.Dictionary; 37import com.android.inputmethod.latin.DictionaryCollection; 38import com.android.inputmethod.latin.DictionaryFacilitator; 39import com.android.inputmethod.latin.DictionaryFactory; 40import com.android.inputmethod.latin.PrevWordsInfo; 41import com.android.inputmethod.latin.R; 42import com.android.inputmethod.latin.SuggestedWords.SuggestedWordInfo; 43import com.android.inputmethod.latin.settings.SettingsValuesForSuggestion; 44import com.android.inputmethod.latin.UserBinaryDictionary; 45import com.android.inputmethod.latin.utils.AdditionalSubtypeUtils; 46import com.android.inputmethod.latin.utils.BinaryDictionaryUtils; 47import com.android.inputmethod.latin.utils.CollectionUtils; 48import com.android.inputmethod.latin.utils.LocaleUtils; 49import com.android.inputmethod.latin.utils.ScriptUtils; 50import com.android.inputmethod.latin.utils.StringUtils; 51import com.android.inputmethod.latin.utils.SuggestionResults; 52import com.android.inputmethod.latin.WordComposer; 53 54import java.lang.ref.WeakReference; 55import java.util.ArrayList; 56import java.util.Arrays; 57import java.util.Collections; 58import java.util.HashMap; 59import java.util.HashSet; 60import java.util.Iterator; 61import java.util.Locale; 62import java.util.Map; 63import java.util.TreeMap; 64import java.util.concurrent.ConcurrentHashMap; 65import java.util.concurrent.ConcurrentLinkedQueue; 66import java.util.concurrent.Semaphore; 67import java.util.concurrent.TimeUnit; 68 69/** 70 * Service for spell checking, using LatinIME's dictionaries and mechanisms. 71 */ 72public 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 private static String getKeyboardLayoutNameForScript(final int script) { 159 switch (script) { 160 case ScriptUtils.SCRIPT_LATIN: 161 return "qwerty"; 162 case ScriptUtils.SCRIPT_CYRILLIC: 163 return "east_slavic"; 164 case ScriptUtils.SCRIPT_GREEK: 165 return "greek"; 166 default: 167 throw new RuntimeException("Wrong script supplied: " + script); 168 } 169 } 170 171 @Override 172 public void onSharedPreferenceChanged(final SharedPreferences prefs, final String key) { 173 if (!PREF_USE_CONTACTS_KEY.equals(key)) return; 174 final boolean useContactsDictionary = prefs.getBoolean(PREF_USE_CONTACTS_KEY, true); 175 if (useContactsDictionary != mUseContactsDictionary) { 176 mSemaphore.acquireUninterruptibly(MAX_NUM_OF_THREADS_READ_DICTIONARY); 177 try { 178 mUseContactsDictionary = useContactsDictionary; 179 for (final Locale locale : mCachedLocales) { 180 final DictionaryFacilitator dictionaryFacilitator = 181 mDictionaryFacilitatorCache.get(locale); 182 resetDictionariesForLocale(this /* context */, 183 dictionaryFacilitator, locale, mUseContactsDictionary); 184 } 185 } finally { 186 mSemaphore.release(MAX_NUM_OF_THREADS_READ_DICTIONARY); 187 } 188 } 189 } 190 191 @Override 192 public Session createSession() { 193 // Should not refer to AndroidSpellCheckerSession directly considering 194 // that AndroidSpellCheckerSession may be overlaid. 195 return AndroidSpellCheckerSessionFactory.newInstance(this); 196 } 197 198 /** 199 * Returns an empty SuggestionsInfo with flags signaling the word is not in the dictionary. 200 * @param reportAsTypo whether this should include the flag LOOKS_LIKE_TYPO, for red underline. 201 * @return the empty SuggestionsInfo with the appropriate flags set. 202 */ 203 public static SuggestionsInfo getNotInDictEmptySuggestions(final boolean reportAsTypo) { 204 return new SuggestionsInfo(reportAsTypo ? SuggestionsInfo.RESULT_ATTR_LOOKS_LIKE_TYPO : 0, 205 EMPTY_STRING_ARRAY); 206 } 207 208 /** 209 * Returns an empty suggestionInfo with flags signaling the word is in the dictionary. 210 * @return the empty SuggestionsInfo with the appropriate flags set. 211 */ 212 public static SuggestionsInfo getInDictEmptySuggestions() { 213 return new SuggestionsInfo(SuggestionsInfo.RESULT_ATTR_IN_THE_DICTIONARY, 214 EMPTY_STRING_ARRAY); 215 } 216 217 public SuggestionsGatherer newSuggestionsGatherer(final String text, int maxLength) { 218 return new SuggestionsGatherer(text, mRecommendedThreshold, maxLength); 219 } 220 221 // TODO: remove this class and replace it by storage local to the session. 222 public static final class SuggestionsGatherer { 223 public static final class Result { 224 public final String[] mSuggestions; 225 public final boolean mHasRecommendedSuggestions; 226 public Result(final String[] gatheredSuggestions, 227 final boolean hasRecommendedSuggestions) { 228 mSuggestions = gatheredSuggestions; 229 mHasRecommendedSuggestions = hasRecommendedSuggestions; 230 } 231 } 232 233 private final ArrayList<String> mSuggestions; 234 private final ArrayList<Integer> mScores; 235 private final String mOriginalText; 236 private final float mRecommendedThreshold; 237 private final int mMaxLength; 238 239 SuggestionsGatherer(final String originalText, final float recommendedThreshold, 240 final int maxLength) { 241 mOriginalText = originalText; 242 mRecommendedThreshold = recommendedThreshold; 243 mMaxLength = maxLength; 244 mSuggestions = new ArrayList<>(); 245 mScores = new ArrayList<>(); 246 } 247 248 public void addResults(final SuggestionResults suggestionResults) { 249 if (suggestionResults == null) { 250 return; 251 } 252 // suggestionResults is sorted. 253 for (final SuggestedWordInfo suggestedWordInfo : suggestionResults) { 254 mSuggestions.add(suggestedWordInfo.mWord); 255 mScores.add(suggestedWordInfo.mScore); 256 } 257 } 258 259 public Result getResults(final int capitalizeType, final Locale locale) { 260 final String[] gatheredSuggestions; 261 final boolean hasRecommendedSuggestions; 262 if (mSuggestions.isEmpty()) { 263 gatheredSuggestions = null; 264 hasRecommendedSuggestions = false; 265 } else { 266 if (DBG) { 267 for (int i = 0; i < mSuggestions.size(); i++) { 268 Log.i(TAG, "" + mScores.get(i) + " " + mSuggestions.get(i)); 269 } 270 } 271 StringUtils.removeDupes(mSuggestions); 272 if (StringUtils.CAPITALIZE_ALL == capitalizeType) { 273 for (int i = 0; i < mSuggestions.size(); ++i) { 274 // get(i) returns a CharSequence which is actually a String so .toString() 275 // should return the same object. 276 mSuggestions.set(i, mSuggestions.get(i).toString().toUpperCase(locale)); 277 } 278 } else if (StringUtils.CAPITALIZE_FIRST == capitalizeType) { 279 for (int i = 0; i < mSuggestions.size(); ++i) { 280 // Likewise 281 mSuggestions.set(i, StringUtils.capitalizeFirstCodePoint( 282 mSuggestions.get(i).toString(), locale)); 283 } 284 } 285 // This returns a String[], while toArray() returns an Object[] which cannot be cast 286 // into a String[]. 287 gatheredSuggestions = mSuggestions.toArray(EMPTY_STRING_ARRAY); 288 289 final int bestScore = mScores.get(0); 290 final String bestSuggestion = mSuggestions.get(0); 291 final float normalizedScore = 292 BinaryDictionaryUtils.calcNormalizedScore( 293 mOriginalText, bestSuggestion.toString(), bestScore); 294 hasRecommendedSuggestions = (normalizedScore > mRecommendedThreshold); 295 if (DBG) { 296 Log.i(TAG, "Best suggestion : " + bestSuggestion + ", score " + bestScore); 297 Log.i(TAG, "Normalized score = " + normalizedScore 298 + " (threshold " + mRecommendedThreshold 299 + ") => hasRecommendedSuggestions = " + hasRecommendedSuggestions); 300 } 301 } 302 return new Result(gatheredSuggestions, hasRecommendedSuggestions); 303 } 304 } 305 306 public boolean isValidWord(final Locale locale, final String word) { 307 mSemaphore.acquireUninterruptibly(); 308 try { 309 DictionaryFacilitator dictionaryFacilitatorForLocale = 310 getDictionaryFacilitatorForLocaleLocked(locale); 311 return dictionaryFacilitatorForLocale.isValidWord(word, false /* igroreCase */); 312 } finally { 313 mSemaphore.release(); 314 } 315 } 316 317 public SuggestionResults getSuggestionResults(final Locale locale, final WordComposer composer, 318 final PrevWordsInfo prevWordsInfo, final ProximityInfo proximityInfo) { 319 Integer sessionId = null; 320 mSemaphore.acquireUninterruptibly(); 321 try { 322 sessionId = mSessionIdPool.poll(); 323 DictionaryFacilitator dictionaryFacilitatorForLocale = 324 getDictionaryFacilitatorForLocaleLocked(locale); 325 return dictionaryFacilitatorForLocale.getSuggestionResults(composer, prevWordsInfo, 326 proximityInfo, mSettingsValuesForSuggestion, sessionId); 327 } finally { 328 if (sessionId != null) { 329 mSessionIdPool.add(sessionId); 330 } 331 mSemaphore.release(); 332 } 333 } 334 335 public boolean hasMainDictionaryForLocale(final Locale locale) { 336 mSemaphore.acquireUninterruptibly(); 337 try { 338 final DictionaryFacilitator dictionaryFacilitator = 339 getDictionaryFacilitatorForLocaleLocked(locale); 340 return dictionaryFacilitator.hasInitializedMainDictionary(); 341 } finally { 342 mSemaphore.release(); 343 } 344 } 345 346 private DictionaryFacilitator getDictionaryFacilitatorForLocaleLocked(final Locale locale) { 347 DictionaryFacilitator dictionaryFacilitatorForLocale = 348 mDictionaryFacilitatorCache.get(locale); 349 if (dictionaryFacilitatorForLocale == null) { 350 dictionaryFacilitatorForLocale = new DictionaryFacilitator(); 351 mDictionaryFacilitatorCache.put(locale, dictionaryFacilitatorForLocale); 352 mCachedLocales.add(locale); 353 resetDictionariesForLocale(this /* context */, dictionaryFacilitatorForLocale, 354 locale, mUseContactsDictionary); 355 } 356 return dictionaryFacilitatorForLocale; 357 } 358 359 private static void resetDictionariesForLocale(final Context context, 360 final DictionaryFacilitator dictionaryFacilitator, final Locale locale, 361 final boolean useContactsDictionary) { 362 dictionaryFacilitator.resetDictionariesWithDictNamePrefix(context, locale, 363 useContactsDictionary, false /* usePersonalizedDicts */, 364 false /* forceReloadMainDictionary */, null /* listener */, 365 DICTIONARY_NAME_PREFIX); 366 for (int i = 0; i < MAX_RETRY_COUNT_FOR_WAITING_FOR_LOADING_DICT; i++) { 367 try { 368 dictionaryFacilitator.waitForLoadingMainDictionary( 369 WAIT_FOR_LOADING_MAIN_DICT_IN_MILLISECONDS, TimeUnit.MILLISECONDS); 370 return; 371 } catch (final InterruptedException e) { 372 Log.i(TAG, "Interrupted during waiting for loading main dictionary.", e); 373 if (i < MAX_RETRY_COUNT_FOR_WAITING_FOR_LOADING_DICT - 1) { 374 Log.i(TAG, "Retry", e); 375 } else { 376 Log.w(TAG, "Give up retrying. Retried " 377 + MAX_RETRY_COUNT_FOR_WAITING_FOR_LOADING_DICT + " times.", e); 378 } 379 } 380 } 381 } 382 383 @Override 384 public boolean onUnbind(final Intent intent) { 385 mSemaphore.acquireUninterruptibly(MAX_NUM_OF_THREADS_READ_DICTIONARY); 386 try { 387 mDictionaryFacilitatorCache.evictAll(); 388 mCachedLocales.clear(); 389 } finally { 390 mSemaphore.release(MAX_NUM_OF_THREADS_READ_DICTIONARY); 391 } 392 mKeyboardCache.clear(); 393 return false; 394 } 395 396 public Keyboard getKeyboardForLocale(final Locale locale) { 397 Keyboard keyboard = mKeyboardCache.get(locale); 398 if (keyboard == null) { 399 keyboard = createKeyboardForLocale(locale); 400 if (keyboard != null) { 401 mKeyboardCache.put(locale, keyboard); 402 } 403 } 404 return keyboard; 405 } 406 407 private Keyboard createKeyboardForLocale(final Locale locale) { 408 final int script = ScriptUtils.getScriptFromSpellCheckerLocale(locale); 409 final String keyboardLayoutName = getKeyboardLayoutNameForScript(script); 410 final InputMethodSubtype subtype = AdditionalSubtypeUtils.createDummyAdditionalSubtype( 411 locale.toString(), keyboardLayoutName); 412 final KeyboardLayoutSet keyboardLayoutSet = createKeyboardSetForSpellChecker(subtype); 413 return keyboardLayoutSet.getKeyboard(KeyboardId.ELEMENT_ALPHABET); 414 } 415 416 private KeyboardLayoutSet createKeyboardSetForSpellChecker(final InputMethodSubtype subtype) { 417 final EditorInfo editorInfo = new EditorInfo(); 418 editorInfo.inputType = InputType.TYPE_CLASS_TEXT; 419 final KeyboardLayoutSet.Builder builder = new KeyboardLayoutSet.Builder(this, editorInfo); 420 builder.setKeyboardGeometry( 421 SPELLCHECKER_DUMMY_KEYBOARD_WIDTH, SPELLCHECKER_DUMMY_KEYBOARD_HEIGHT); 422 builder.setSubtype(subtype); 423 builder.setIsSpellChecker(true /* isSpellChecker */); 424 builder.disableTouchPositionCorrectionData(); 425 return builder.build(); 426 } 427} 428