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