AndroidSpellCheckerService.java revision 9e76304d6004c43c3149bc2df460af2a00b18a4f
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 int[] mScores; 235 private final String mOriginalText; 236 private final float mRecommendedThreshold; 237 private final int mMaxLength; 238 private int mLength = 0; 239 240 SuggestionsGatherer(final String originalText, final float recommendedThreshold, 241 final int maxLength) { 242 mOriginalText = originalText; 243 mRecommendedThreshold = recommendedThreshold; 244 mMaxLength = maxLength; 245 mSuggestions = new ArrayList<>(maxLength + 1); 246 mScores = new int[mMaxLength]; 247 } 248 249 synchronized public boolean addWord(char[] word, int[] spaceIndices, int wordOffset, 250 int wordLength, int score) { 251 final int positionIndex = Arrays.binarySearch(mScores, 0, mLength, score); 252 // binarySearch returns the index if the element exists, and -<insertion index> - 1 253 // if it doesn't. See documentation for binarySearch. 254 final int insertIndex = positionIndex >= 0 ? positionIndex : -positionIndex - 1; 255 256 // Weak <- insertIndex == 0, ..., insertIndex == mLength -> Strong 257 if (insertIndex == 0 && mLength >= mMaxLength) { 258 return true; 259 } 260 261 final String wordString = new String(word, wordOffset, wordLength); 262 if (mLength < mMaxLength) { 263 final int copyLen = mLength - insertIndex; 264 ++mLength; 265 System.arraycopy(mScores, insertIndex, mScores, insertIndex + 1, copyLen); 266 mSuggestions.add(insertIndex, wordString); 267 mScores[insertIndex] = score; 268 } else { 269 System.arraycopy(mScores, 1, mScores, 0, insertIndex - 1); 270 mSuggestions.add(insertIndex, wordString); 271 mSuggestions.remove(0); 272 mScores[insertIndex - 1] = score; 273 } 274 275 return true; 276 } 277 278 public Result getResults(final int capitalizeType, final Locale locale) { 279 final String[] gatheredSuggestions; 280 final boolean hasRecommendedSuggestions; 281 if (0 == mLength) { 282 gatheredSuggestions = null; 283 hasRecommendedSuggestions = false; 284 } else { 285 if (DBG) { 286 if (mLength != mSuggestions.size()) { 287 Log.e(TAG, "Suggestion size is not the same as stored mLength"); 288 } 289 for (int i = mLength - 1; i >= 0; --i) { 290 Log.i(TAG, "" + mScores[i] + " " + mSuggestions.get(i)); 291 } 292 } 293 Collections.reverse(mSuggestions); 294 StringUtils.removeDupes(mSuggestions); 295 if (StringUtils.CAPITALIZE_ALL == capitalizeType) { 296 for (int i = 0; i < mSuggestions.size(); ++i) { 297 // get(i) returns a CharSequence which is actually a String so .toString() 298 // should return the same object. 299 mSuggestions.set(i, mSuggestions.get(i).toString().toUpperCase(locale)); 300 } 301 } else if (StringUtils.CAPITALIZE_FIRST == capitalizeType) { 302 for (int i = 0; i < mSuggestions.size(); ++i) { 303 // Likewise 304 mSuggestions.set(i, StringUtils.capitalizeFirstCodePoint( 305 mSuggestions.get(i).toString(), locale)); 306 } 307 } 308 // This returns a String[], while toArray() returns an Object[] which cannot be cast 309 // into a String[]. 310 gatheredSuggestions = mSuggestions.toArray(EMPTY_STRING_ARRAY); 311 312 final int bestScore = mScores[mLength - 1]; 313 final String bestSuggestion = mSuggestions.get(0); 314 final float normalizedScore = 315 BinaryDictionaryUtils.calcNormalizedScore( 316 mOriginalText, bestSuggestion.toString(), bestScore); 317 hasRecommendedSuggestions = (normalizedScore > mRecommendedThreshold); 318 if (DBG) { 319 Log.i(TAG, "Best suggestion : " + bestSuggestion + ", score " + bestScore); 320 Log.i(TAG, "Normalized score = " + normalizedScore 321 + " (threshold " + mRecommendedThreshold 322 + ") => hasRecommendedSuggestions = " + hasRecommendedSuggestions); 323 } 324 } 325 return new Result(gatheredSuggestions, hasRecommendedSuggestions); 326 } 327 } 328 329 public boolean isValidWord(final Locale locale, final String word) { 330 mSemaphore.acquireUninterruptibly(); 331 try { 332 DictionaryFacilitator dictionaryFacilitatorForLocale = 333 getDictionaryFacilitatorForLocaleLocked(locale); 334 return dictionaryFacilitatorForLocale.isValidWord(word, false /* igroreCase */); 335 } finally { 336 mSemaphore.release(); 337 } 338 } 339 340 public SuggestionResults getSuggestionResults(final Locale locale, final WordComposer composer, 341 final PrevWordsInfo prevWordsInfo, final ProximityInfo proximityInfo) { 342 Integer sessionId = null; 343 mSemaphore.acquireUninterruptibly(); 344 try { 345 sessionId = mSessionIdPool.poll(); 346 DictionaryFacilitator dictionaryFacilitatorForLocale = 347 getDictionaryFacilitatorForLocaleLocked(locale); 348 return dictionaryFacilitatorForLocale.getSuggestionResults(composer, prevWordsInfo, 349 proximityInfo, mSettingsValuesForSuggestion, sessionId); 350 } finally { 351 if (sessionId != null) { 352 mSessionIdPool.add(sessionId); 353 } 354 mSemaphore.release(); 355 } 356 } 357 358 public boolean hasMainDictionaryForLocale(final Locale locale) { 359 mSemaphore.acquireUninterruptibly(); 360 try { 361 final DictionaryFacilitator dictionaryFacilitator = 362 getDictionaryFacilitatorForLocaleLocked(locale); 363 return dictionaryFacilitator.hasInitializedMainDictionary(); 364 } finally { 365 mSemaphore.release(); 366 } 367 } 368 369 private DictionaryFacilitator getDictionaryFacilitatorForLocaleLocked(final Locale locale) { 370 DictionaryFacilitator dictionaryFacilitatorForLocale = 371 mDictionaryFacilitatorCache.get(locale); 372 if (dictionaryFacilitatorForLocale == null) { 373 dictionaryFacilitatorForLocale = new DictionaryFacilitator(); 374 mDictionaryFacilitatorCache.put(locale, dictionaryFacilitatorForLocale); 375 mCachedLocales.add(locale); 376 resetDictionariesForLocale(this /* context */, dictionaryFacilitatorForLocale, 377 locale, mUseContactsDictionary); 378 } 379 return dictionaryFacilitatorForLocale; 380 } 381 382 private static void resetDictionariesForLocale(final Context context, 383 final DictionaryFacilitator dictionaryFacilitator, final Locale locale, 384 final boolean useContactsDictionary) { 385 dictionaryFacilitator.resetDictionariesWithDictNamePrefix(context, locale, 386 useContactsDictionary, false /* usePersonalizedDicts */, 387 false /* forceReloadMainDictionary */, null /* listener */, 388 DICTIONARY_NAME_PREFIX); 389 for (int i = 0; i < MAX_RETRY_COUNT_FOR_WAITING_FOR_LOADING_DICT; i++) { 390 try { 391 dictionaryFacilitator.waitForLoadingMainDictionary( 392 WAIT_FOR_LOADING_MAIN_DICT_IN_MILLISECONDS, TimeUnit.MILLISECONDS); 393 return; 394 } catch (final InterruptedException e) { 395 Log.i(TAG, "Interrupted during waiting for loading main dictionary.", e); 396 if (i < MAX_RETRY_COUNT_FOR_WAITING_FOR_LOADING_DICT - 1) { 397 Log.i(TAG, "Retry", e); 398 } else { 399 Log.w(TAG, "Give up retrying. Retried " 400 + MAX_RETRY_COUNT_FOR_WAITING_FOR_LOADING_DICT + " times.", e); 401 } 402 } 403 } 404 } 405 406 @Override 407 public boolean onUnbind(final Intent intent) { 408 mSemaphore.acquireUninterruptibly(MAX_NUM_OF_THREADS_READ_DICTIONARY); 409 try { 410 mDictionaryFacilitatorCache.evictAll(); 411 mCachedLocales.clear(); 412 } finally { 413 mSemaphore.release(MAX_NUM_OF_THREADS_READ_DICTIONARY); 414 } 415 mKeyboardCache.clear(); 416 return false; 417 } 418 419 public Keyboard getKeyboardForLocale(final Locale locale) { 420 Keyboard keyboard = mKeyboardCache.get(locale); 421 if (keyboard == null) { 422 keyboard = createKeyboardForLocale(locale); 423 if (keyboard != null) { 424 mKeyboardCache.put(locale, keyboard); 425 } 426 } 427 return keyboard; 428 } 429 430 private Keyboard createKeyboardForLocale(final Locale locale) { 431 final int script = ScriptUtils.getScriptFromSpellCheckerLocale(locale); 432 final String keyboardLayoutName = getKeyboardLayoutNameForScript(script); 433 final InputMethodSubtype subtype = AdditionalSubtypeUtils.createDummyAdditionalSubtype( 434 locale.toString(), keyboardLayoutName); 435 final KeyboardLayoutSet keyboardLayoutSet = createKeyboardSetForSpellChecker(subtype); 436 return keyboardLayoutSet.getKeyboard(KeyboardId.ELEMENT_ALPHABET); 437 } 438 439 private KeyboardLayoutSet createKeyboardSetForSpellChecker(final InputMethodSubtype subtype) { 440 final EditorInfo editorInfo = new EditorInfo(); 441 editorInfo.inputType = InputType.TYPE_CLASS_TEXT; 442 final KeyboardLayoutSet.Builder builder = new KeyboardLayoutSet.Builder(this, editorInfo); 443 builder.setKeyboardGeometry( 444 SPELLCHECKER_DUMMY_KEYBOARD_WIDTH, SPELLCHECKER_DUMMY_KEYBOARD_HEIGHT); 445 builder.setSubtype(subtype); 446 builder.setIsSpellChecker(true /* isSpellChecker */); 447 builder.disableTouchPositionCorrectionData(); 448 return builder.build(); 449 } 450} 451