SpellChecker.java revision be5f49fb6e17e0b9588d3b94022b7e3eb6d47317
1/* 2 * Copyright (C) 2011 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17package android.widget; 18 19import android.content.Context; 20import android.text.Editable; 21import android.text.Selection; 22import android.text.SpannableStringBuilder; 23import android.text.Spanned; 24import android.text.method.WordIterator; 25import android.text.style.SpellCheckSpan; 26import android.text.style.SuggestionSpan; 27import android.view.textservice.SpellCheckerSession; 28import android.view.textservice.SpellCheckerSession.SpellCheckerSessionListener; 29import android.view.textservice.SuggestionsInfo; 30import android.view.textservice.TextInfo; 31import android.view.textservice.TextServicesManager; 32 33import com.android.internal.util.ArrayUtils; 34 35import java.text.BreakIterator; 36import java.util.Locale; 37 38 39/** 40 * Helper class for TextView. Bridge between the TextView and the Dictionnary service. 41 * 42 * @hide 43 */ 44public class SpellChecker implements SpellCheckerSessionListener { 45 46 // No more than this number of words will be parsed on each iteration to ensure a minimum 47 // lock of the UI thread 48 public static final int MAX_NUMBER_OF_WORDS = 50; 49 50 // Rough estimate, such that the word iterator interval usually does not need to be shifted 51 public static final int AVERAGE_WORD_LENGTH = 7; 52 53 // When parsing, use a character window of that size. Will be shifted if needed 54 public static final int WORD_ITERATOR_INTERVAL = AVERAGE_WORD_LENGTH * MAX_NUMBER_OF_WORDS; 55 56 // Pause between each spell check to keep the UI smooth 57 private final static int SPELL_PAUSE_DURATION = 400; // milliseconds 58 59 private final TextView mTextView; 60 61 SpellCheckerSession mSpellCheckerSession; 62 final int mCookie; 63 64 // Paired arrays for the (id, spellCheckSpan) pair. A negative id means the associated 65 // SpellCheckSpan has been recycled and can be-reused. 66 // Contains null SpellCheckSpans after index mLength. 67 private int[] mIds; 68 private SpellCheckSpan[] mSpellCheckSpans; 69 // The mLength first elements of the above arrays have been initialized 70 private int mLength; 71 72 // Parsers on chunck of text, cutting text into words that will be checked 73 private SpellParser[] mSpellParsers = new SpellParser[0]; 74 75 private int mSpanSequenceCounter = 0; 76 77 private Locale mCurrentLocale; 78 79 // Shared by all SpellParsers. Cannot be shared with TextView since it may be used 80 // concurrently due to the asynchronous nature of onGetSuggestions. 81 private WordIterator mWordIterator; 82 83 private TextServicesManager mTextServicesManager; 84 85 private Runnable mSpellRunnable; 86 87 public SpellChecker(TextView textView) { 88 mTextView = textView; 89 90 // Arbitrary: these arrays will automatically double their sizes on demand 91 final int size = ArrayUtils.idealObjectArraySize(1); 92 mIds = new int[size]; 93 mSpellCheckSpans = new SpellCheckSpan[size]; 94 95 setLocale(mTextView.getTextServicesLocale()); 96 97 mCookie = hashCode(); 98 } 99 100 private void resetSession() { 101 closeSession(); 102 103 mTextServicesManager = (TextServicesManager) mTextView.getContext(). 104 getSystemService(Context.TEXT_SERVICES_MANAGER_SERVICE); 105 if (!mTextServicesManager.isSpellCheckerEnabled()) { 106 mSpellCheckerSession = null; 107 } else { 108 mSpellCheckerSession = mTextServicesManager.newSpellCheckerSession( 109 null /* Bundle not currently used by the textServicesManager */, 110 mCurrentLocale, this, 111 false /* means any available languages from current spell checker */); 112 } 113 114 // Restore SpellCheckSpans in pool 115 for (int i = 0; i < mLength; i++) { 116 mSpellCheckSpans[i].setSpellCheckInProgress(false); 117 mIds[i] = -1; 118 } 119 mLength = 0; 120 121 // Remove existing misspelled SuggestionSpans 122 mTextView.removeMisspelledSpans((Editable) mTextView.getText()); 123 124 // This class is the listener for locale change: warn other locale-aware objects 125 mTextView.onLocaleChanged(); 126 } 127 128 private void setLocale(Locale locale) { 129 mCurrentLocale = locale; 130 131 resetSession(); 132 133 // Change SpellParsers' wordIterator locale 134 mWordIterator = new WordIterator(locale); 135 136 // This class is the listener for locale change: warn other locale-aware objects 137 mTextView.onLocaleChanged(); 138 } 139 140 /** 141 * @return true if a spell checker session has successfully been created. Returns false if not, 142 * for instance when spell checking has been disabled in settings. 143 */ 144 private boolean isSessionActive() { 145 return mSpellCheckerSession != null; 146 } 147 148 public void closeSession() { 149 if (mSpellCheckerSession != null) { 150 mSpellCheckerSession.close(); 151 } 152 153 final int length = mSpellParsers.length; 154 for (int i = 0; i < length; i++) { 155 mSpellParsers[i].finish(); 156 } 157 158 if (mSpellRunnable != null) { 159 mTextView.removeCallbacks(mSpellRunnable); 160 } 161 } 162 163 private int nextSpellCheckSpanIndex() { 164 for (int i = 0; i < mLength; i++) { 165 if (mIds[i] < 0) return i; 166 } 167 168 if (mLength == mSpellCheckSpans.length) { 169 final int newSize = mLength * 2; 170 int[] newIds = new int[newSize]; 171 SpellCheckSpan[] newSpellCheckSpans = new SpellCheckSpan[newSize]; 172 System.arraycopy(mIds, 0, newIds, 0, mLength); 173 System.arraycopy(mSpellCheckSpans, 0, newSpellCheckSpans, 0, mLength); 174 mIds = newIds; 175 mSpellCheckSpans = newSpellCheckSpans; 176 } 177 178 mSpellCheckSpans[mLength] = new SpellCheckSpan(); 179 mLength++; 180 return mLength - 1; 181 } 182 183 private void addSpellCheckSpan(Editable editable, int start, int end) { 184 final int index = nextSpellCheckSpanIndex(); 185 editable.setSpan(mSpellCheckSpans[index], start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 186 mIds[index] = mSpanSequenceCounter++; 187 } 188 189 public void removeSpellCheckSpan(SpellCheckSpan spellCheckSpan) { 190 for (int i = 0; i < mLength; i++) { 191 if (mSpellCheckSpans[i] == spellCheckSpan) { 192 mSpellCheckSpans[i].setSpellCheckInProgress(false); 193 mIds[i] = -1; 194 return; 195 } 196 } 197 } 198 199 public void onSelectionChanged() { 200 spellCheck(); 201 } 202 203 public void spellCheck(int start, int end) { 204 final Locale locale = mTextView.getTextServicesLocale(); 205 if (mCurrentLocale == null || (!(mCurrentLocale.equals(locale)))) { 206 setLocale(locale); 207 // Re-check the entire text 208 start = 0; 209 end = mTextView.getText().length(); 210 } else { 211 final boolean spellCheckerActivated = mTextServicesManager.isSpellCheckerEnabled(); 212 if (isSessionActive() != spellCheckerActivated) { 213 // Spell checker has been turned of or off since last spellCheck 214 resetSession(); 215 } 216 } 217 218 if (!isSessionActive()) return; 219 220 final int length = mSpellParsers.length; 221 for (int i = 0; i < length; i++) { 222 final SpellParser spellParser = mSpellParsers[i]; 223 if (spellParser.isFinished()) { 224 spellParser.init(start, end); 225 spellParser.parse(); 226 return; 227 } 228 } 229 230 // No available parser found in pool, create a new one 231 SpellParser[] newSpellParsers = new SpellParser[length + 1]; 232 System.arraycopy(mSpellParsers, 0, newSpellParsers, 0, length); 233 mSpellParsers = newSpellParsers; 234 235 SpellParser spellParser = new SpellParser(); 236 mSpellParsers[length] = spellParser; 237 spellParser.init(start, end); 238 spellParser.parse(); 239 } 240 241 private void spellCheck() { 242 if (mSpellCheckerSession == null) return; 243 244 Editable editable = (Editable) mTextView.getText(); 245 final int selectionStart = Selection.getSelectionStart(editable); 246 final int selectionEnd = Selection.getSelectionEnd(editable); 247 248 TextInfo[] textInfos = new TextInfo[mLength]; 249 int textInfosCount = 0; 250 251 for (int i = 0; i < mLength; i++) { 252 final SpellCheckSpan spellCheckSpan = mSpellCheckSpans[i]; 253 if (spellCheckSpan.isSpellCheckInProgress()) continue; 254 255 final int start = editable.getSpanStart(spellCheckSpan); 256 final int end = editable.getSpanEnd(spellCheckSpan); 257 258 // Do not check this word if the user is currently editing it 259 if (start >= 0 && end > start && (selectionEnd < start || selectionStart > end)) { 260 final String word = (editable instanceof SpannableStringBuilder) ? 261 ((SpannableStringBuilder) editable).substring(start, end) : 262 editable.subSequence(start, end).toString(); 263 spellCheckSpan.setSpellCheckInProgress(true); 264 textInfos[textInfosCount++] = new TextInfo(word, mCookie, mIds[i]); 265 } 266 } 267 268 if (textInfosCount > 0) { 269 if (textInfosCount < textInfos.length) { 270 TextInfo[] textInfosCopy = new TextInfo[textInfosCount]; 271 System.arraycopy(textInfos, 0, textInfosCopy, 0, textInfosCount); 272 textInfos = textInfosCopy; 273 } 274 275 mSpellCheckerSession.getSuggestions(textInfos, SuggestionSpan.SUGGESTIONS_MAX_SIZE, 276 false /* TODO Set sequentialWords to true for initial spell check */); 277 } 278 } 279 280 @Override 281 public void onGetSuggestions(SuggestionsInfo[] results) { 282 Editable editable = (Editable) mTextView.getText(); 283 284 for (int i = 0; i < results.length; i++) { 285 SuggestionsInfo suggestionsInfo = results[i]; 286 if (suggestionsInfo.getCookie() != mCookie) continue; 287 final int sequenceNumber = suggestionsInfo.getSequence(); 288 289 for (int j = 0; j < mLength; j++) { 290 if (sequenceNumber == mIds[j]) { 291 final int attributes = suggestionsInfo.getSuggestionsAttributes(); 292 boolean isInDictionary = 293 ((attributes & SuggestionsInfo.RESULT_ATTR_IN_THE_DICTIONARY) > 0); 294 boolean looksLikeTypo = 295 ((attributes & SuggestionsInfo.RESULT_ATTR_LOOKS_LIKE_TYPO) > 0); 296 297 SpellCheckSpan spellCheckSpan = mSpellCheckSpans[j]; 298 299 if (!isInDictionary && looksLikeTypo) { 300 createMisspelledSuggestionSpan(editable, suggestionsInfo, spellCheckSpan); 301 } 302 303 editable.removeSpan(spellCheckSpan); 304 break; 305 } 306 } 307 } 308 309 scheduleNewSpellCheck(); 310 } 311 312 private void scheduleNewSpellCheck() { 313 if (mSpellRunnable == null) { 314 mSpellRunnable = new Runnable() { 315 @Override 316 public void run() { 317 final int length = mSpellParsers.length; 318 for (int i = 0; i < length; i++) { 319 final SpellParser spellParser = mSpellParsers[i]; 320 if (!spellParser.isFinished()) { 321 spellParser.parse(); 322 break; // run one spell parser at a time to bound running time 323 } 324 } 325 } 326 }; 327 } else { 328 mTextView.removeCallbacks(mSpellRunnable); 329 } 330 331 mTextView.postDelayed(mSpellRunnable, SPELL_PAUSE_DURATION); 332 } 333 334 private void createMisspelledSuggestionSpan(Editable editable, SuggestionsInfo suggestionsInfo, 335 SpellCheckSpan spellCheckSpan) { 336 final int start = editable.getSpanStart(spellCheckSpan); 337 final int end = editable.getSpanEnd(spellCheckSpan); 338 if (start < 0 || end <= start) return; // span was removed in the meantime 339 340 // Other suggestion spans may exist on that region, with identical suggestions, filter 341 // them out to avoid duplicates. 342 SuggestionSpan[] suggestionSpans = editable.getSpans(start, end, SuggestionSpan.class); 343 final int length = suggestionSpans.length; 344 for (int i = 0; i < length; i++) { 345 final int spanStart = editable.getSpanStart(suggestionSpans[i]); 346 final int spanEnd = editable.getSpanEnd(suggestionSpans[i]); 347 if (spanStart != start || spanEnd != end) { 348 // Nulled (to avoid new array allocation) if not on that exact same region 349 suggestionSpans[i] = null; 350 } 351 } 352 353 final int suggestionsCount = suggestionsInfo.getSuggestionsCount(); 354 String[] suggestions; 355 if (suggestionsCount <= 0) { 356 // A negative suggestion count is possible 357 suggestions = ArrayUtils.emptyArray(String.class); 358 } else { 359 int numberOfSuggestions = 0; 360 suggestions = new String[suggestionsCount]; 361 362 for (int i = 0; i < suggestionsCount; i++) { 363 final String spellSuggestion = suggestionsInfo.getSuggestionAt(i); 364 if (spellSuggestion == null) break; 365 boolean suggestionFound = false; 366 367 for (int j = 0; j < length && !suggestionFound; j++) { 368 if (suggestionSpans[j] == null) break; 369 370 String[] suggests = suggestionSpans[j].getSuggestions(); 371 for (int k = 0; k < suggests.length; k++) { 372 if (spellSuggestion.equals(suggests[k])) { 373 // The suggestion is already provided by an other SuggestionSpan 374 suggestionFound = true; 375 break; 376 } 377 } 378 } 379 380 if (!suggestionFound) { 381 suggestions[numberOfSuggestions++] = spellSuggestion; 382 } 383 } 384 385 if (numberOfSuggestions != suggestionsCount) { 386 String[] newSuggestions = new String[numberOfSuggestions]; 387 System.arraycopy(suggestions, 0, newSuggestions, 0, numberOfSuggestions); 388 suggestions = newSuggestions; 389 } 390 } 391 392 SuggestionSpan suggestionSpan = new SuggestionSpan(mTextView.getContext(), suggestions, 393 SuggestionSpan.FLAG_EASY_CORRECT | SuggestionSpan.FLAG_MISSPELLED); 394 editable.setSpan(suggestionSpan, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 395 396 mTextView.invalidateRegion(start, end); 397 } 398 399 private class SpellParser { 400 private Object mRange = new Object(); 401 402 public void init(int start, int end) { 403 ((Editable) mTextView.getText()).setSpan(mRange, start, end, 404 Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 405 } 406 407 public void finish() { 408 ((Editable) mTextView.getText()).removeSpan(mRange); 409 } 410 411 public boolean isFinished() { 412 return ((Editable) mTextView.getText()).getSpanStart(mRange) < 0; 413 } 414 415 public void parse() { 416 Editable editable = (Editable) mTextView.getText(); 417 // Iterate over the newly added text and schedule new SpellCheckSpans 418 final int start = editable.getSpanStart(mRange); 419 final int end = editable.getSpanEnd(mRange); 420 421 int wordIteratorWindowEnd = Math.min(end, start + WORD_ITERATOR_INTERVAL); 422 mWordIterator.setCharSequence(editable, start, wordIteratorWindowEnd); 423 424 // Move back to the beginning of the current word, if any 425 int wordStart = mWordIterator.preceding(start); 426 int wordEnd; 427 if (wordStart == BreakIterator.DONE) { 428 wordEnd = mWordIterator.following(start); 429 if (wordEnd != BreakIterator.DONE) { 430 wordStart = mWordIterator.getBeginning(wordEnd); 431 } 432 } else { 433 wordEnd = mWordIterator.getEnd(wordStart); 434 } 435 if (wordEnd == BreakIterator.DONE) { 436 editable.removeSpan(mRange); 437 return; 438 } 439 440 // We need to expand by one character because we want to include the spans that 441 // end/start at position start/end respectively. 442 SpellCheckSpan[] spellCheckSpans = editable.getSpans(start - 1, end + 1, 443 SpellCheckSpan.class); 444 SuggestionSpan[] suggestionSpans = editable.getSpans(start - 1, end + 1, 445 SuggestionSpan.class); 446 447 int wordCount = 0; 448 boolean scheduleOtherSpellCheck = false; 449 450 while (wordStart <= end) { 451 if (wordEnd >= start && wordEnd > wordStart) { 452 if (wordCount >= MAX_NUMBER_OF_WORDS) { 453 scheduleOtherSpellCheck = true; 454 break; 455 } 456 457 // A new word has been created across the interval boundaries with this edit. 458 // Previous spans (ended on start / started on end) removed, not valid anymore 459 if (wordStart < start && wordEnd > start) { 460 removeSpansAt(editable, start, spellCheckSpans); 461 removeSpansAt(editable, start, suggestionSpans); 462 } 463 464 if (wordStart < end && wordEnd > end) { 465 removeSpansAt(editable, end, spellCheckSpans); 466 removeSpansAt(editable, end, suggestionSpans); 467 } 468 469 // Do not create new boundary spans if they already exist 470 boolean createSpellCheckSpan = true; 471 if (wordEnd == start) { 472 for (int i = 0; i < spellCheckSpans.length; i++) { 473 final int spanEnd = editable.getSpanEnd(spellCheckSpans[i]); 474 if (spanEnd == start) { 475 createSpellCheckSpan = false; 476 break; 477 } 478 } 479 } 480 481 if (wordStart == end) { 482 for (int i = 0; i < spellCheckSpans.length; i++) { 483 final int spanStart = editable.getSpanStart(spellCheckSpans[i]); 484 if (spanStart == end) { 485 createSpellCheckSpan = false; 486 break; 487 } 488 } 489 } 490 491 if (createSpellCheckSpan) { 492 addSpellCheckSpan(editable, wordStart, wordEnd); 493 } 494 wordCount++; 495 } 496 497 // iterate word by word 498 int originalWordEnd = wordEnd; 499 wordEnd = mWordIterator.following(wordEnd); 500 if ((wordIteratorWindowEnd < end) && 501 (wordEnd == BreakIterator.DONE || wordEnd >= wordIteratorWindowEnd)) { 502 wordIteratorWindowEnd = Math.min(end, originalWordEnd + WORD_ITERATOR_INTERVAL); 503 mWordIterator.setCharSequence(editable, originalWordEnd, wordIteratorWindowEnd); 504 wordEnd = mWordIterator.following(originalWordEnd); 505 } 506 if (wordEnd == BreakIterator.DONE) break; 507 wordStart = mWordIterator.getBeginning(wordEnd); 508 if (wordStart == BreakIterator.DONE) { 509 break; 510 } 511 } 512 513 if (scheduleOtherSpellCheck) { 514 editable.setSpan(mRange, wordStart, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 515 } else { 516 editable.removeSpan(mRange); 517 } 518 519 spellCheck(); 520 } 521 522 private <T> void removeSpansAt(Editable editable, int offset, T[] spans) { 523 final int length = spans.length; 524 for (int i = 0; i < length; i++) { 525 final T span = spans[i]; 526 final int start = editable.getSpanStart(span); 527 if (start > offset) continue; 528 final int end = editable.getSpanEnd(span); 529 if (end < offset) continue; 530 editable.removeSpan(span); 531 } 532 } 533 } 534} 535