AndroidSpellCheckerService.java revision f6adff6227a15af105dbf39c57213a24bf16780b
1/* 2 * Copyright (C) 2011 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 * use this file except in compliance with the License. You may obtain a copy of 6 * 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, WITHOUT 12 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 * License for the specific language governing permissions and limitations under 14 * 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.TextUtils; 24import android.util.Log; 25import android.util.LruCache; 26import android.view.textservice.SentenceSuggestionsInfo; 27import android.view.textservice.SuggestionsInfo; 28import android.view.textservice.TextInfo; 29 30import com.android.inputmethod.compat.SuggestionsInfoCompatUtils; 31import com.android.inputmethod.keyboard.ProximityInfo; 32import com.android.inputmethod.latin.BinaryDictionary; 33import com.android.inputmethod.latin.Dictionary; 34import com.android.inputmethod.latin.Dictionary.WordCallback; 35import com.android.inputmethod.latin.DictionaryCollection; 36import com.android.inputmethod.latin.DictionaryFactory; 37import com.android.inputmethod.latin.LatinIME; 38import com.android.inputmethod.latin.LocaleUtils; 39import com.android.inputmethod.latin.R; 40import com.android.inputmethod.latin.StringUtils; 41import com.android.inputmethod.latin.SynchronouslyLoadedContactsBinaryDictionary; 42import com.android.inputmethod.latin.SynchronouslyLoadedContactsDictionary; 43import com.android.inputmethod.latin.SynchronouslyLoadedUserBinaryDictionary; 44import com.android.inputmethod.latin.SynchronouslyLoadedUserDictionary; 45import com.android.inputmethod.latin.WhitelistDictionary; 46import com.android.inputmethod.latin.WordComposer; 47 48import java.lang.ref.WeakReference; 49import java.util.ArrayList; 50import java.util.Arrays; 51import java.util.Collections; 52import java.util.HashSet; 53import java.util.Iterator; 54import java.util.Locale; 55import java.util.Map; 56import java.util.TreeMap; 57 58/** 59 * Service for spell checking, using LatinIME's dictionaries and mechanisms. 60 */ 61public class AndroidSpellCheckerService extends SpellCheckerService 62 implements SharedPreferences.OnSharedPreferenceChangeListener { 63 private static final String TAG = AndroidSpellCheckerService.class.getSimpleName(); 64 private static final boolean DBG = false; 65 private static final int POOL_SIZE = 2; 66 67 public static final String PREF_USE_CONTACTS_KEY = "pref_spellcheck_use_contacts"; 68 69 private static final int CAPITALIZE_NONE = 0; // No caps, or mixed case 70 private static final int CAPITALIZE_FIRST = 1; // First only 71 private static final int CAPITALIZE_ALL = 2; // All caps 72 73 private final static String[] EMPTY_STRING_ARRAY = new String[0]; 74 private Map<String, DictionaryPool> mDictionaryPools = 75 Collections.synchronizedMap(new TreeMap<String, DictionaryPool>()); 76 private Map<String, Dictionary> mUserDictionaries = 77 Collections.synchronizedMap(new TreeMap<String, Dictionary>()); 78 private Map<String, Dictionary> mWhitelistDictionaries = 79 Collections.synchronizedMap(new TreeMap<String, Dictionary>()); 80 private Dictionary mContactsDictionary; 81 82 // The threshold for a candidate to be offered as a suggestion. 83 private float mSuggestionThreshold; 84 // The threshold for a suggestion to be considered "recommended". 85 private float mRecommendedThreshold; 86 // Whether to use the contacts dictionary 87 private boolean mUseContactsDictionary; 88 private final Object mUseContactsLock = new Object(); 89 90 private final HashSet<WeakReference<DictionaryCollection>> mDictionaryCollectionsList = 91 new HashSet<WeakReference<DictionaryCollection>>(); 92 93 public static final int SCRIPT_LATIN = 0; 94 public static final int SCRIPT_CYRILLIC = 1; 95 private static final String SINGLE_QUOTE = "\u0027"; 96 private static final String APOSTROPHE = "\u2019"; 97 private static final TreeMap<String, Integer> mLanguageToScript; 98 static { 99 // List of the supported languages and their associated script. We won't check 100 // words written in another script than the selected script, because we know we 101 // don't have those in our dictionary so we will underline everything and we 102 // will never have any suggestions, so it makes no sense checking them. 103 mLanguageToScript = new TreeMap<String, Integer>(); 104 mLanguageToScript.put("en", SCRIPT_LATIN); 105 mLanguageToScript.put("fr", SCRIPT_LATIN); 106 mLanguageToScript.put("de", SCRIPT_LATIN); 107 mLanguageToScript.put("nl", SCRIPT_LATIN); 108 mLanguageToScript.put("cs", SCRIPT_LATIN); 109 mLanguageToScript.put("es", SCRIPT_LATIN); 110 mLanguageToScript.put("it", SCRIPT_LATIN); 111 mLanguageToScript.put("ru", SCRIPT_CYRILLIC); 112 } 113 114 @Override public void onCreate() { 115 super.onCreate(); 116 mSuggestionThreshold = 117 Float.parseFloat(getString(R.string.spellchecker_suggestion_threshold_value)); 118 mRecommendedThreshold = 119 Float.parseFloat(getString(R.string.spellchecker_recommended_threshold_value)); 120 final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); 121 prefs.registerOnSharedPreferenceChangeListener(this); 122 onSharedPreferenceChanged(prefs, PREF_USE_CONTACTS_KEY); 123 } 124 125 private static int getScriptFromLocale(final Locale locale) { 126 final Integer script = mLanguageToScript.get(locale.getLanguage()); 127 if (null == script) { 128 throw new RuntimeException("We have been called with an unsupported language: \"" 129 + locale.getLanguage() + "\". Framework bug?"); 130 } 131 return script; 132 } 133 134 @Override 135 public void onSharedPreferenceChanged(final SharedPreferences prefs, final String key) { 136 if (!PREF_USE_CONTACTS_KEY.equals(key)) return; 137 synchronized(mUseContactsLock) { 138 mUseContactsDictionary = prefs.getBoolean(PREF_USE_CONTACTS_KEY, true); 139 if (mUseContactsDictionary) { 140 startUsingContactsDictionaryLocked(); 141 } else { 142 stopUsingContactsDictionaryLocked(); 143 } 144 } 145 } 146 147 private void startUsingContactsDictionaryLocked() { 148 if (null == mContactsDictionary) { 149 mContactsDictionary = new SynchronouslyLoadedContactsDictionary(this); 150 } 151 final Iterator<WeakReference<DictionaryCollection>> iterator = 152 mDictionaryCollectionsList.iterator(); 153 while (iterator.hasNext()) { 154 final WeakReference<DictionaryCollection> dictRef = iterator.next(); 155 final DictionaryCollection dict = dictRef.get(); 156 if (null == dict) { 157 iterator.remove(); 158 } else { 159 dict.addDictionary(mContactsDictionary); 160 } 161 } 162 } 163 164 private void stopUsingContactsDictionaryLocked() { 165 if (null == mContactsDictionary) return; 166 final Dictionary contactsDict = mContactsDictionary; 167 // TODO: revert to the concrete type when USE_BINARY_CONTACTS_DICTIONARY is no longer needed 168 mContactsDictionary = null; 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.removeDictionary(contactsDict); 178 } 179 } 180 contactsDict.close(); 181 } 182 183 @Override 184 public Session createSession() { 185 return new AndroidSpellCheckerSession(this); 186 } 187 188 private static SuggestionsInfo getNotInDictEmptySuggestions() { 189 return new SuggestionsInfo(0, EMPTY_STRING_ARRAY); 190 } 191 192 private static SuggestionsInfo getInDictEmptySuggestions() { 193 return new SuggestionsInfo(SuggestionsInfo.RESULT_ATTR_IN_THE_DICTIONARY, 194 EMPTY_STRING_ARRAY); 195 } 196 197 private static class SuggestionsGatherer implements WordCallback { 198 public static class Result { 199 public final String[] mSuggestions; 200 public final boolean mHasRecommendedSuggestions; 201 public Result(final String[] gatheredSuggestions, 202 final boolean hasRecommendedSuggestions) { 203 mSuggestions = gatheredSuggestions; 204 mHasRecommendedSuggestions = hasRecommendedSuggestions; 205 } 206 } 207 208 private final ArrayList<CharSequence> mSuggestions; 209 private final int[] mScores; 210 private final String mOriginalText; 211 private final float mSuggestionThreshold; 212 private final float mRecommendedThreshold; 213 private final int mMaxLength; 214 private int mLength = 0; 215 216 // The two following attributes are only ever filled if the requested max length 217 // is 0 (or less, which is treated the same). 218 private String mBestSuggestion = null; 219 private int mBestScore = Integer.MIN_VALUE; // As small as possible 220 221 SuggestionsGatherer(final String originalText, final float suggestionThreshold, 222 final float recommendedThreshold, final int maxLength) { 223 mOriginalText = originalText; 224 mSuggestionThreshold = suggestionThreshold; 225 mRecommendedThreshold = recommendedThreshold; 226 mMaxLength = maxLength; 227 mSuggestions = new ArrayList<CharSequence>(maxLength + 1); 228 mScores = new int[mMaxLength]; 229 } 230 231 @Override 232 synchronized public boolean addWord(char[] word, int wordOffset, int wordLength, int score, 233 int dicTypeId, int dataType) { 234 final int positionIndex = Arrays.binarySearch(mScores, 0, mLength, score); 235 // binarySearch returns the index if the element exists, and -<insertion index> - 1 236 // if it doesn't. See documentation for binarySearch. 237 final int insertIndex = positionIndex >= 0 ? positionIndex : -positionIndex - 1; 238 239 if (insertIndex == 0 && mLength >= mMaxLength) { 240 // In the future, we may want to keep track of the best suggestion score even if 241 // we are asked for 0 suggestions. In this case, we can use the following 242 // (tested) code to keep it: 243 // If the maxLength is 0 (should never be less, but if it is, it's treated as 0) 244 // then we need to keep track of the best suggestion in mBestScore and 245 // mBestSuggestion. This is so that we know whether the best suggestion makes 246 // the score cutoff, since we need to know that to return a meaningful 247 // looksLikeTypo. 248 // if (0 >= mMaxLength) { 249 // if (score > mBestScore) { 250 // mBestScore = score; 251 // mBestSuggestion = new String(word, wordOffset, wordLength); 252 // } 253 // } 254 return true; 255 } 256 if (insertIndex >= mMaxLength) { 257 // We found a suggestion, but its score is too weak to be kept considering 258 // the suggestion limit. 259 return true; 260 } 261 262 // Compute the normalized score and skip this word if it's normalized score does not 263 // make the threshold. 264 final String wordString = new String(word, wordOffset, wordLength); 265 final float normalizedScore = 266 BinaryDictionary.calcNormalizedScore(mOriginalText, wordString, score); 267 if (normalizedScore < mSuggestionThreshold) { 268 if (DBG) Log.i(TAG, wordString + " does not make the score threshold"); 269 return true; 270 } 271 272 if (mLength < mMaxLength) { 273 final int copyLen = mLength - insertIndex; 274 ++mLength; 275 System.arraycopy(mScores, insertIndex, mScores, insertIndex + 1, copyLen); 276 mSuggestions.add(insertIndex, wordString); 277 } else { 278 System.arraycopy(mScores, 1, mScores, 0, insertIndex); 279 mSuggestions.add(insertIndex, wordString); 280 mSuggestions.remove(0); 281 } 282 mScores[insertIndex] = score; 283 284 return true; 285 } 286 287 public Result getResults(final int capitalizeType, final Locale locale) { 288 final String[] gatheredSuggestions; 289 final boolean hasRecommendedSuggestions; 290 if (0 == mLength) { 291 // Either we found no suggestions, or we found some BUT the max length was 0. 292 // If we found some mBestSuggestion will not be null. If it is null, then 293 // we found none, regardless of the max length. 294 if (null == mBestSuggestion) { 295 gatheredSuggestions = null; 296 hasRecommendedSuggestions = false; 297 } else { 298 gatheredSuggestions = EMPTY_STRING_ARRAY; 299 final float normalizedScore = BinaryDictionary.calcNormalizedScore( 300 mOriginalText, mBestSuggestion, mBestScore); 301 hasRecommendedSuggestions = (normalizedScore > mRecommendedThreshold); 302 } 303 } else { 304 if (DBG) { 305 if (mLength != mSuggestions.size()) { 306 Log.e(TAG, "Suggestion size is not the same as stored mLength"); 307 } 308 for (int i = mLength - 1; i >= 0; --i) { 309 Log.i(TAG, "" + mScores[i] + " " + mSuggestions.get(i)); 310 } 311 } 312 Collections.reverse(mSuggestions); 313 StringUtils.removeDupes(mSuggestions); 314 if (CAPITALIZE_ALL == capitalizeType) { 315 for (int i = 0; i < mSuggestions.size(); ++i) { 316 // get(i) returns a CharSequence which is actually a String so .toString() 317 // should return the same object. 318 mSuggestions.set(i, mSuggestions.get(i).toString().toUpperCase(locale)); 319 } 320 } else if (CAPITALIZE_FIRST == capitalizeType) { 321 for (int i = 0; i < mSuggestions.size(); ++i) { 322 // Likewise 323 mSuggestions.set(i, StringUtils.toTitleCase( 324 mSuggestions.get(i).toString(), locale)); 325 } 326 } 327 // This returns a String[], while toArray() returns an Object[] which cannot be cast 328 // into a String[]. 329 gatheredSuggestions = mSuggestions.toArray(EMPTY_STRING_ARRAY); 330 331 final int bestScore = mScores[mLength - 1]; 332 final CharSequence bestSuggestion = mSuggestions.get(0); 333 final float normalizedScore = 334 BinaryDictionary.calcNormalizedScore( 335 mOriginalText, bestSuggestion.toString(), bestScore); 336 hasRecommendedSuggestions = (normalizedScore > mRecommendedThreshold); 337 if (DBG) { 338 Log.i(TAG, "Best suggestion : " + bestSuggestion + ", score " + bestScore); 339 Log.i(TAG, "Normalized score = " + normalizedScore 340 + " (threshold " + mRecommendedThreshold 341 + ") => hasRecommendedSuggestions = " + hasRecommendedSuggestions); 342 } 343 } 344 return new Result(gatheredSuggestions, hasRecommendedSuggestions); 345 } 346 } 347 348 @Override 349 public boolean onUnbind(final Intent intent) { 350 closeAllDictionaries(); 351 return false; 352 } 353 354 private void closeAllDictionaries() { 355 final Map<String, DictionaryPool> oldPools = mDictionaryPools; 356 mDictionaryPools = Collections.synchronizedMap(new TreeMap<String, DictionaryPool>()); 357 final Map<String, Dictionary> oldUserDictionaries = mUserDictionaries; 358 mUserDictionaries = Collections.synchronizedMap(new TreeMap<String, Dictionary>()); 359 final Map<String, Dictionary> oldWhitelistDictionaries = mWhitelistDictionaries; 360 mWhitelistDictionaries = Collections.synchronizedMap(new TreeMap<String, Dictionary>()); 361 for (DictionaryPool pool : oldPools.values()) { 362 pool.close(); 363 } 364 for (Dictionary dict : oldUserDictionaries.values()) { 365 dict.close(); 366 } 367 for (Dictionary dict : oldWhitelistDictionaries.values()) { 368 dict.close(); 369 } 370 synchronized (mUseContactsLock) { 371 if (null != mContactsDictionary) { 372 // The synchronously loaded contacts dictionary should have been in one 373 // or several pools, but it is shielded against multiple closing and it's 374 // safe to call it several times. 375 final Dictionary dictToClose = mContactsDictionary; 376 // TODO: revert to the concrete type when USE_BINARY_CONTACTS_DICTIONARY is no 377 // longer needed 378 mContactsDictionary = null; 379 dictToClose.close(); 380 } 381 } 382 } 383 384 private DictionaryPool getDictionaryPool(final String locale) { 385 DictionaryPool pool = mDictionaryPools.get(locale); 386 if (null == pool) { 387 final Locale localeObject = LocaleUtils.constructLocaleFromString(locale); 388 pool = new DictionaryPool(POOL_SIZE, this, localeObject); 389 mDictionaryPools.put(locale, pool); 390 } 391 return pool; 392 } 393 394 public DictAndProximity createDictAndProximity(final Locale locale) { 395 final int script = getScriptFromLocale(locale); 396 final ProximityInfo proximityInfo = ProximityInfo.createSpellCheckerProximityInfo( 397 SpellCheckerProximityInfo.getProximityForScript(script), 398 SpellCheckerProximityInfo.ROW_SIZE, 399 SpellCheckerProximityInfo.PROXIMITY_GRID_WIDTH, 400 SpellCheckerProximityInfo.PROXIMITY_GRID_HEIGHT); 401 final DictionaryCollection dictionaryCollection = 402 DictionaryFactory.createMainDictionaryFromManager(this, locale, 403 true /* useFullEditDistance */); 404 final String localeStr = locale.toString(); 405 Dictionary userDictionary = mUserDictionaries.get(localeStr); 406 if (null == userDictionary) { 407 if (LatinIME.USE_BINARY_USER_DICTIONARY) { 408 userDictionary = new SynchronouslyLoadedUserBinaryDictionary(this, localeStr, true); 409 } else { 410 userDictionary = new SynchronouslyLoadedUserDictionary(this, localeStr, true); 411 } 412 mUserDictionaries.put(localeStr, userDictionary); 413 } 414 dictionaryCollection.addDictionary(userDictionary); 415 Dictionary whitelistDictionary = mWhitelistDictionaries.get(localeStr); 416 if (null == whitelistDictionary) { 417 whitelistDictionary = new WhitelistDictionary(this, locale); 418 mWhitelistDictionaries.put(localeStr, whitelistDictionary); 419 } 420 dictionaryCollection.addDictionary(whitelistDictionary); 421 synchronized (mUseContactsLock) { 422 if (mUseContactsDictionary) { 423 if (null == mContactsDictionary) { 424 // TODO: revert to the concrete type when USE_BINARY_CONTACTS_DICTIONARY is no 425 // longer needed 426 if (LatinIME.USE_BINARY_CONTACTS_DICTIONARY) { 427 mContactsDictionary = new SynchronouslyLoadedContactsBinaryDictionary(this); 428 } else { 429 mContactsDictionary = new SynchronouslyLoadedContactsDictionary(this); 430 } 431 } 432 } 433 dictionaryCollection.addDictionary(mContactsDictionary); 434 mDictionaryCollectionsList.add( 435 new WeakReference<DictionaryCollection>(dictionaryCollection)); 436 } 437 return new DictAndProximity(dictionaryCollection, proximityInfo); 438 } 439 440 // This method assumes the text is not empty or null. 441 private static int getCapitalizationType(String text) { 442 // If the first char is not uppercase, then the word is either all lower case, 443 // and in either case we return CAPITALIZE_NONE. 444 if (!Character.isUpperCase(text.codePointAt(0))) return CAPITALIZE_NONE; 445 final int len = text.length(); 446 int capsCount = 1; 447 for (int i = 1; i < len; i = text.offsetByCodePoints(i, 1)) { 448 if (1 != capsCount && i != capsCount) break; 449 if (Character.isUpperCase(text.codePointAt(i))) ++capsCount; 450 } 451 // We know the first char is upper case. So we want to test if either everything 452 // else is lower case, or if everything else is upper case. If the string is 453 // exactly one char long, then we will arrive here with capsCount 1, and this is 454 // correct, too. 455 if (1 == capsCount) return CAPITALIZE_FIRST; 456 return (len == capsCount ? CAPITALIZE_ALL : CAPITALIZE_NONE); 457 } 458 459 private static class AndroidSpellCheckerSession extends Session { 460 // Immutable, but need the locale which is not available in the constructor yet 461 private DictionaryPool mDictionaryPool; 462 // Likewise 463 private Locale mLocale; 464 // Cache this for performance 465 private int mScript; // One of SCRIPT_LATIN or SCRIPT_CYRILLIC for now. 466 467 private final AndroidSpellCheckerService mService; 468 469 private final SuggestionsCache mSuggestionsCache = new SuggestionsCache(); 470 471 private static class SuggestionsParams { 472 public final String[] mSuggestions; 473 public final int mFlags; 474 public SuggestionsParams(String[] suggestions, int flags) { 475 mSuggestions = suggestions; 476 mFlags = flags; 477 } 478 } 479 480 private static class SuggestionsCache { 481 private static final int MAX_CACHE_SIZE = 50; 482 // TODO: support bigram 483 private final LruCache<String, SuggestionsParams> mUnigramSuggestionsInfoCache = 484 new LruCache<String, SuggestionsParams>(MAX_CACHE_SIZE); 485 486 public SuggestionsParams getSuggestionsFromCache(String query) { 487 return mUnigramSuggestionsInfoCache.get(query); 488 } 489 490 public void putSuggestionsToCache(String query, String[] suggestions, int flags) { 491 if (suggestions == null || TextUtils.isEmpty(query)) { 492 return; 493 } 494 mUnigramSuggestionsInfoCache.put(query, new SuggestionsParams(suggestions, flags)); 495 } 496 497 public void remove(String key) { 498 mUnigramSuggestionsInfoCache.remove(key); 499 } 500 } 501 502 AndroidSpellCheckerSession(final AndroidSpellCheckerService service) { 503 mService = service; 504 } 505 506 @Override 507 public void onCreate() { 508 final String localeString = getLocale(); 509 mDictionaryPool = mService.getDictionaryPool(localeString); 510 mLocale = LocaleUtils.constructLocaleFromString(localeString); 511 mScript = getScriptFromLocale(mLocale); 512 } 513 514 /* 515 * Returns whether the code point is a letter that makes sense for the specified 516 * locale for this spell checker. 517 * The dictionaries supported by Latin IME are described in res/xml/spellchecker.xml 518 * and is limited to EFIGS languages and Russian. 519 * Hence at the moment this explicitly tests for Cyrillic characters or Latin characters 520 * as appropriate, and explicitly excludes CJK, Arabic and Hebrew characters. 521 */ 522 private static boolean isLetterCheckableByLanguage(final int codePoint, 523 final int script) { 524 switch (script) { 525 case SCRIPT_LATIN: 526 // Our supported latin script dictionaries (EFIGS) at the moment only include 527 // characters in the C0, C1, Latin Extended A and B, IPA extensions unicode 528 // blocks. As it happens, those are back-to-back in the code range 0x40 to 0x2AF, 529 // so the below is a very efficient way to test for it. As for the 0-0x3F, it's 530 // excluded from isLetter anyway. 531 return codePoint <= 0x2AF && Character.isLetter(codePoint); 532 case SCRIPT_CYRILLIC: 533 // All Cyrillic characters are in the 400~52F block. There are some in the upper 534 // Unicode range, but they are archaic characters that are not used in modern 535 // russian and are not used by our dictionary. 536 return codePoint >= 0x400 && codePoint <= 0x52F && Character.isLetter(codePoint); 537 default: 538 // Should never come here 539 throw new RuntimeException("Impossible value of script: " + script); 540 } 541 } 542 543 /** 544 * Finds out whether a particular string should be filtered out of spell checking. 545 * 546 * This will loosely match URLs, numbers, symbols. To avoid always underlining words that 547 * we know we will never recognize, this accepts a script identifier that should be one 548 * of the SCRIPT_* constants defined above, to rule out quickly characters from very 549 * different languages. 550 * 551 * @param text the string to evaluate. 552 * @param script the identifier for the script this spell checker recognizes 553 * @return true if we should filter this text out, false otherwise 554 */ 555 private static boolean shouldFilterOut(final String text, final int script) { 556 if (TextUtils.isEmpty(text) || text.length() <= 1) return true; 557 558 // TODO: check if an equivalent processing can't be done more quickly with a 559 // compiled regexp. 560 // Filter by first letter 561 final int firstCodePoint = text.codePointAt(0); 562 // Filter out words that don't start with a letter or an apostrophe 563 if (!isLetterCheckableByLanguage(firstCodePoint, script) 564 && '\'' != firstCodePoint) return true; 565 566 // Filter contents 567 final int length = text.length(); 568 int letterCount = 0; 569 for (int i = 0; i < length; i = text.offsetByCodePoints(i, 1)) { 570 final int codePoint = text.codePointAt(i); 571 // Any word containing a '@' is probably an e-mail address 572 // Any word containing a '/' is probably either an ad-hoc combination of two 573 // words or a URI - in either case we don't want to spell check that 574 if ('@' == codePoint || '/' == codePoint) return true; 575 if (isLetterCheckableByLanguage(codePoint, script)) ++letterCount; 576 } 577 // Guestimate heuristic: perform spell checking if at least 3/4 of the characters 578 // in this word are letters 579 return (letterCount * 4 < length * 3); 580 } 581 582 private SentenceSuggestionsInfo fixWronglyInvalidatedWordWithSingleQuote( 583 TextInfo ti, SentenceSuggestionsInfo ssi) { 584 final String typedText = ti.getText(); 585 if (!typedText.contains(SINGLE_QUOTE)) { 586 return null; 587 } 588 final int N = ssi.getSuggestionsCount(); 589 final ArrayList<Integer> additionalOffsets = new ArrayList<Integer>(); 590 final ArrayList<Integer> additionalLengths = new ArrayList<Integer>(); 591 final ArrayList<SuggestionsInfo> additionalSuggestionsInfos = 592 new ArrayList<SuggestionsInfo>(); 593 for (int i = 0; i < N; ++i) { 594 final SuggestionsInfo si = ssi.getSuggestionsInfoAt(i); 595 final int flags = si.getSuggestionsAttributes(); 596 if ((flags & SuggestionsInfo.RESULT_ATTR_IN_THE_DICTIONARY) == 0) { 597 continue; 598 } 599 final int offset = ssi.getOffsetAt(i); 600 final int length = ssi.getLengthAt(i); 601 final String subText = typedText.substring(offset, offset + length); 602 if (!subText.contains(SINGLE_QUOTE)) { 603 continue; 604 } 605 final String[] splitTexts = subText.split(SINGLE_QUOTE, -1); 606 if (splitTexts == null || splitTexts.length <= 1) { 607 continue; 608 } 609 final int splitNum = splitTexts.length; 610 for (int j = 0; j < splitNum; ++j) { 611 final String splitText = splitTexts[j]; 612 if (TextUtils.isEmpty(splitText)) { 613 continue; 614 } 615 if (mSuggestionsCache.getSuggestionsFromCache(splitText) == null) { 616 continue; 617 } 618 final int newLength = splitText.length(); 619 // Neither RESULT_ATTR_IN_THE_DICTIONARY nor RESULT_ATTR_LOOKS_LIKE_TYPO 620 final int newFlags = 0; 621 final SuggestionsInfo newSi = new SuggestionsInfo(newFlags, EMPTY_STRING_ARRAY); 622 newSi.setCookieAndSequence(si.getCookie(), si.getSequence()); 623 if (DBG) { 624 Log.d(TAG, "Override and remove old span over: " 625 + splitText + ", " + offset + "," + newLength); 626 } 627 additionalOffsets.add(offset); 628 additionalLengths.add(newLength); 629 additionalSuggestionsInfos.add(newSi); 630 } 631 } 632 final int additionalSize = additionalOffsets.size(); 633 if (additionalSize <= 0) { 634 return null; 635 } 636 final int suggestionsSize = N + additionalSize; 637 final int[] newOffsets = new int[suggestionsSize]; 638 final int[] newLengths = new int[suggestionsSize]; 639 final SuggestionsInfo[] newSuggestionsInfos = new SuggestionsInfo[suggestionsSize]; 640 int i; 641 for (i = 0; i < N; ++i) { 642 newOffsets[i] = ssi.getOffsetAt(i); 643 newLengths[i] = ssi.getLengthAt(i); 644 newSuggestionsInfos[i] = ssi.getSuggestionsInfoAt(i); 645 } 646 for (; i < suggestionsSize; ++i) { 647 newOffsets[i] = additionalOffsets.get(i - N); 648 newLengths[i] = additionalLengths.get(i - N); 649 newSuggestionsInfos[i] = additionalSuggestionsInfos.get(i - N); 650 } 651 return new SentenceSuggestionsInfo(newSuggestionsInfos, newOffsets, newLengths); 652 } 653 654 @Override 655 public SentenceSuggestionsInfo[] onGetSentenceSuggestionsMultiple( 656 TextInfo[] textInfos, int suggestionsLimit) { 657 final SentenceSuggestionsInfo[] retval = super.onGetSentenceSuggestionsMultiple( 658 textInfos, suggestionsLimit); 659 if (retval == null || retval.length != textInfos.length) { 660 return retval; 661 } 662 for (int i = 0; i < retval.length; ++i) { 663 final SentenceSuggestionsInfo tempSsi = 664 fixWronglyInvalidatedWordWithSingleQuote(textInfos[i], retval[i]); 665 if (tempSsi != null) { 666 retval[i] = tempSsi; 667 } 668 } 669 return retval; 670 } 671 672 // Note : this must be reentrant 673 /** 674 * Gets a list of suggestions for a specific string. This returns a list of possible 675 * corrections for the text passed as an argument. It may split or group words, and 676 * even perform grammatical analysis. 677 */ 678 @Override 679 public SuggestionsInfo onGetSuggestions(final TextInfo textInfo, 680 final int suggestionsLimit) { 681 try { 682 final String inText = textInfo.getText(); 683 final SuggestionsParams cachedSuggestionsParams = 684 mSuggestionsCache.getSuggestionsFromCache(inText); 685 if (cachedSuggestionsParams != null) { 686 if (DBG) { 687 Log.d(TAG, "Cache hit: " + inText + ", " + cachedSuggestionsParams.mFlags); 688 } 689 return new SuggestionsInfo( 690 cachedSuggestionsParams.mFlags, cachedSuggestionsParams.mSuggestions); 691 } 692 693 if (shouldFilterOut(inText, mScript)) { 694 DictAndProximity dictInfo = null; 695 try { 696 dictInfo = mDictionaryPool.takeOrGetNull(); 697 if (null == dictInfo) return getNotInDictEmptySuggestions(); 698 return dictInfo.mDictionary.isValidWord(inText) ? 699 getInDictEmptySuggestions() : getNotInDictEmptySuggestions(); 700 } finally { 701 if (null != dictInfo) { 702 if (!mDictionaryPool.offer(dictInfo)) { 703 Log.e(TAG, "Can't re-insert a dictionary into its pool"); 704 } 705 } 706 } 707 } 708 final String text = inText.replaceAll(APOSTROPHE, SINGLE_QUOTE); 709 710 // TODO: Don't gather suggestions if the limit is <= 0 unless necessary 711 final SuggestionsGatherer suggestionsGatherer = new SuggestionsGatherer(text, 712 mService.mSuggestionThreshold, mService.mRecommendedThreshold, 713 suggestionsLimit); 714 final WordComposer composer = new WordComposer(); 715 final int length = text.length(); 716 for (int i = 0; i < length; i = text.offsetByCodePoints(i, 1)) { 717 final int codePoint = text.codePointAt(i); 718 // The getXYForCodePointAndScript method returns (Y << 16) + X 719 final int xy = SpellCheckerProximityInfo.getXYForCodePointAndScript( 720 codePoint, mScript); 721 if (SpellCheckerProximityInfo.NOT_A_COORDINATE_PAIR == xy) { 722 composer.add(codePoint, WordComposer.NOT_A_COORDINATE, 723 WordComposer.NOT_A_COORDINATE, null); 724 } else { 725 composer.add(codePoint, xy & 0xFFFF, xy >> 16, null); 726 } 727 } 728 729 final int capitalizeType = getCapitalizationType(text); 730 boolean isInDict = true; 731 DictAndProximity dictInfo = null; 732 try { 733 dictInfo = mDictionaryPool.takeOrGetNull(); 734 if (null == dictInfo) return getNotInDictEmptySuggestions(); 735 dictInfo.mDictionary.getWords(composer, null, suggestionsGatherer, 736 dictInfo.mProximityInfo); 737 isInDict = dictInfo.mDictionary.isValidWord(text); 738 if (!isInDict && CAPITALIZE_NONE != capitalizeType) { 739 // We want to test the word again if it's all caps or first caps only. 740 // If it's fully down, we already tested it, if it's mixed case, we don't 741 // want to test a lowercase version of it. 742 isInDict = dictInfo.mDictionary.isValidWord(text.toLowerCase(mLocale)); 743 } 744 } finally { 745 if (null != dictInfo) { 746 if (!mDictionaryPool.offer(dictInfo)) { 747 Log.e(TAG, "Can't re-insert a dictionary into its pool"); 748 } 749 } 750 } 751 752 final SuggestionsGatherer.Result result = suggestionsGatherer.getResults( 753 capitalizeType, mLocale); 754 755 if (DBG) { 756 Log.i(TAG, "Spell checking results for " + text + " with suggestion limit " 757 + suggestionsLimit); 758 Log.i(TAG, "IsInDict = " + isInDict); 759 Log.i(TAG, "LooksLikeTypo = " + (!isInDict)); 760 Log.i(TAG, "HasRecommendedSuggestions = " + result.mHasRecommendedSuggestions); 761 if (null != result.mSuggestions) { 762 for (String suggestion : result.mSuggestions) { 763 Log.i(TAG, suggestion); 764 } 765 } 766 } 767 768 final int flags = 769 (isInDict ? SuggestionsInfo.RESULT_ATTR_IN_THE_DICTIONARY 770 : SuggestionsInfo.RESULT_ATTR_LOOKS_LIKE_TYPO) 771 | (result.mHasRecommendedSuggestions 772 ? SuggestionsInfoCompatUtils 773 .getValueOf_RESULT_ATTR_HAS_RECOMMENDED_SUGGESTIONS() 774 : 0); 775 final SuggestionsInfo retval = new SuggestionsInfo(flags, result.mSuggestions); 776 mSuggestionsCache.putSuggestionsToCache(text, result.mSuggestions, flags); 777 return retval; 778 } catch (RuntimeException e) { 779 // Don't kill the keyboard if there is a bug in the spell checker 780 if (DBG) { 781 throw e; 782 } else { 783 Log.e(TAG, "Exception while spellcheking: " + e); 784 return getNotInDictEmptySuggestions(); 785 } 786 } 787 } 788 } 789} 790