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