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