SpellChecker.java revision e1e874854ab8b73dc5f2346108cbfe90dabaea18
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.util.Log; 28import android.view.textservice.SentenceSuggestionsInfo; 29import android.view.textservice.SpellCheckerSession; 30import android.view.textservice.SpellCheckerSession.SpellCheckerSessionListener; 31import android.view.textservice.SuggestionsInfo; 32import android.view.textservice.TextInfo; 33import android.view.textservice.TextServicesManager; 34 35import com.android.internal.util.ArrayUtils; 36 37import java.text.BreakIterator; 38import java.util.Locale; 39 40 41/** 42 * Helper class for TextView. Bridge between the TextView and the Dictionnary service. 43 * 44 * @hide 45 */ 46public class SpellChecker implements SpellCheckerSessionListener { 47 private static final String TAG = SpellChecker.class.getSimpleName(); 48 private static final boolean DBG = false; 49 50 // No more than this number of words will be parsed on each iteration to ensure a minimum 51 // lock of the UI thread 52 public static final int MAX_NUMBER_OF_WORDS = 50; 53 54 // Rough estimate, such that the word iterator interval usually does not need to be shifted 55 public static final int AVERAGE_WORD_LENGTH = 7; 56 57 // When parsing, use a character window of that size. Will be shifted if needed 58 public static final int WORD_ITERATOR_INTERVAL = AVERAGE_WORD_LENGTH * MAX_NUMBER_OF_WORDS; 59 60 // Pause between each spell check to keep the UI smooth 61 private final static int SPELL_PAUSE_DURATION = 400; // milliseconds 62 63 private static final int USE_SPAN_RANGE = -1; 64 65 private final TextView mTextView; 66 67 SpellCheckerSession mSpellCheckerSession; 68 // We assume that the sentence level spell check will always provide better results than words. 69 // Although word SC has a sequential option. 70 private boolean mIsSentenceSpellCheckSupported; 71 final int mCookie; 72 73 // Paired arrays for the (id, spellCheckSpan) pair. A negative id means the associated 74 // SpellCheckSpan has been recycled and can be-reused. 75 // Contains null SpellCheckSpans after index mLength. 76 private int[] mIds; 77 private SpellCheckSpan[] mSpellCheckSpans; 78 // The mLength first elements of the above arrays have been initialized 79 private int mLength; 80 81 // Parsers on chunck of text, cutting text into words that will be checked 82 private SpellParser[] mSpellParsers = new SpellParser[0]; 83 84 private int mSpanSequenceCounter = 0; 85 86 private Locale mCurrentLocale; 87 88 // Shared by all SpellParsers. Cannot be shared with TextView since it may be used 89 // concurrently due to the asynchronous nature of onGetSuggestions. 90 private WordIterator mWordIterator; 91 92 private TextServicesManager mTextServicesManager; 93 94 private Runnable mSpellRunnable; 95 96 public SpellChecker(TextView textView) { 97 mTextView = textView; 98 99 // Arbitrary: these arrays will automatically double their sizes on demand 100 final int size = ArrayUtils.idealObjectArraySize(1); 101 mIds = new int[size]; 102 mSpellCheckSpans = new SpellCheckSpan[size]; 103 104 setLocale(mTextView.getTextServicesLocale()); 105 106 mCookie = hashCode(); 107 } 108 109 private void resetSession() { 110 closeSession(); 111 112 mTextServicesManager = (TextServicesManager) mTextView.getContext(). 113 getSystemService(Context.TEXT_SERVICES_MANAGER_SERVICE); 114 if (!mTextServicesManager.isSpellCheckerEnabled() 115 || mTextServicesManager.getCurrentSpellCheckerSubtype(true) == null) { 116 mSpellCheckerSession = null; 117 } else { 118 mSpellCheckerSession = mTextServicesManager.newSpellCheckerSession( 119 null /* Bundle not currently used by the textServicesManager */, 120 mCurrentLocale, this, 121 false /* means any available languages from current spell checker */); 122 mIsSentenceSpellCheckSupported = true; 123 } 124 125 // Restore SpellCheckSpans in pool 126 for (int i = 0; i < mLength; i++) { 127 mSpellCheckSpans[i].setSpellCheckInProgress(false); 128 mIds[i] = -1; 129 } 130 mLength = 0; 131 132 // Remove existing misspelled SuggestionSpans 133 mTextView.removeMisspelledSpans((Editable) mTextView.getText()); 134 } 135 136 private void setLocale(Locale locale) { 137 mCurrentLocale = locale; 138 139 resetSession(); 140 141 // Change SpellParsers' wordIterator locale 142 mWordIterator = new WordIterator(locale); 143 144 // This class is the listener for locale change: warn other locale-aware objects 145 mTextView.onLocaleChanged(); 146 } 147 148 /** 149 * @return true if a spell checker session has successfully been created. Returns false if not, 150 * for instance when spell checking has been disabled in settings. 151 */ 152 private boolean isSessionActive() { 153 return mSpellCheckerSession != null; 154 } 155 156 public void closeSession() { 157 if (mSpellCheckerSession != null) { 158 mSpellCheckerSession.close(); 159 } 160 161 final int length = mSpellParsers.length; 162 for (int i = 0; i < length; i++) { 163 mSpellParsers[i].stop(); 164 } 165 166 if (mSpellRunnable != null) { 167 mTextView.removeCallbacks(mSpellRunnable); 168 } 169 } 170 171 private int nextSpellCheckSpanIndex() { 172 for (int i = 0; i < mLength; i++) { 173 if (mIds[i] < 0) return i; 174 } 175 176 if (mLength == mSpellCheckSpans.length) { 177 final int newSize = mLength * 2; 178 int[] newIds = new int[newSize]; 179 SpellCheckSpan[] newSpellCheckSpans = new SpellCheckSpan[newSize]; 180 System.arraycopy(mIds, 0, newIds, 0, mLength); 181 System.arraycopy(mSpellCheckSpans, 0, newSpellCheckSpans, 0, mLength); 182 mIds = newIds; 183 mSpellCheckSpans = newSpellCheckSpans; 184 } 185 186 mSpellCheckSpans[mLength] = new SpellCheckSpan(); 187 mLength++; 188 return mLength - 1; 189 } 190 191 private void addSpellCheckSpan(Editable editable, int start, int end) { 192 final int index = nextSpellCheckSpanIndex(); 193 editable.setSpan(mSpellCheckSpans[index], start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 194 mIds[index] = mSpanSequenceCounter++; 195 } 196 197 public void removeSpellCheckSpan(SpellCheckSpan spellCheckSpan) { 198 for (int i = 0; i < mLength; i++) { 199 if (mSpellCheckSpans[i] == spellCheckSpan) { 200 mSpellCheckSpans[i].setSpellCheckInProgress(false); 201 mIds[i] = -1; 202 return; 203 } 204 } 205 } 206 207 public void onSelectionChanged() { 208 spellCheck(); 209 } 210 211 public void spellCheck(int start, int end) { 212 final Locale locale = mTextView.getTextServicesLocale(); 213 if (mCurrentLocale == null || (!(mCurrentLocale.equals(locale)))) { 214 setLocale(locale); 215 // Re-check the entire text 216 start = 0; 217 end = mTextView.getText().length(); 218 } else { 219 final boolean spellCheckerActivated = mTextServicesManager.isSpellCheckerEnabled(); 220 if (isSessionActive() != spellCheckerActivated) { 221 // Spell checker has been turned of or off since last spellCheck 222 resetSession(); 223 } 224 } 225 226 if (!isSessionActive()) return; 227 228 // Find first available SpellParser from pool 229 final int length = mSpellParsers.length; 230 for (int i = 0; i < length; i++) { 231 final SpellParser spellParser = mSpellParsers[i]; 232 if (spellParser.isFinished()) { 233 spellParser.parse(start, end); 234 return; 235 } 236 } 237 238 // No available parser found in pool, create a new one 239 SpellParser[] newSpellParsers = new SpellParser[length + 1]; 240 System.arraycopy(mSpellParsers, 0, newSpellParsers, 0, length); 241 mSpellParsers = newSpellParsers; 242 243 SpellParser spellParser = new SpellParser(); 244 mSpellParsers[length] = spellParser; 245 spellParser.parse(start, end); 246 } 247 248 private void spellCheck() { 249 if (mSpellCheckerSession == null) return; 250 251 Editable editable = (Editable) mTextView.getText(); 252 final int selectionStart = Selection.getSelectionStart(editable); 253 final int selectionEnd = Selection.getSelectionEnd(editable); 254 255 TextInfo[] textInfos = new TextInfo[mLength]; 256 int textInfosCount = 0; 257 258 for (int i = 0; i < mLength; i++) { 259 final SpellCheckSpan spellCheckSpan = mSpellCheckSpans[i]; 260 if (spellCheckSpan.isSpellCheckInProgress()) continue; 261 262 final int start = editable.getSpanStart(spellCheckSpan); 263 final int end = editable.getSpanEnd(spellCheckSpan); 264 265 // Do not check this word if the user is currently editing it 266 if (start >= 0 && end > start && (selectionEnd < start || selectionStart > end)) { 267 final String word = (editable instanceof SpannableStringBuilder) ? 268 ((SpannableStringBuilder) editable).substring(start, end) : 269 editable.subSequence(start, end).toString(); 270 spellCheckSpan.setSpellCheckInProgress(true); 271 textInfos[textInfosCount++] = new TextInfo(word, mCookie, mIds[i]); 272 if (DBG) { 273 Log.d(TAG, "create TextInfo: (" + i + "/" + mLength + ")" + word 274 + ", cookie = " + mCookie + ", seq = " 275 + mIds[i] + ", sel start = " + selectionStart + ", sel end = " 276 + selectionEnd + ", start = " + start + ", end = " + end); 277 } 278 } 279 } 280 281 if (textInfosCount > 0) { 282 if (textInfosCount < textInfos.length) { 283 TextInfo[] textInfosCopy = new TextInfo[textInfosCount]; 284 System.arraycopy(textInfos, 0, textInfosCopy, 0, textInfosCount); 285 textInfos = textInfosCopy; 286 } 287 288 if (mIsSentenceSpellCheckSupported) { 289 mSpellCheckerSession.getSentenceSuggestions( 290 textInfos, SuggestionSpan.SUGGESTIONS_MAX_SIZE); 291 } else { 292 mSpellCheckerSession.getSuggestions(textInfos, SuggestionSpan.SUGGESTIONS_MAX_SIZE, 293 false /* TODO Set sequentialWords to true for initial spell check */); 294 } 295 } 296 } 297 298 private SpellCheckSpan onGetSuggestionsInternal( 299 SuggestionsInfo suggestionsInfo, int offset, int length) { 300 if (suggestionsInfo == null || suggestionsInfo.getCookie() != mCookie) { 301 return null; 302 } 303 final Editable editable = (Editable) mTextView.getText(); 304 final int sequenceNumber = suggestionsInfo.getSequence(); 305 for (int k = 0; k < mLength; ++k) { 306 if (sequenceNumber == mIds[k]) { 307 final int attributes = suggestionsInfo.getSuggestionsAttributes(); 308 final boolean isInDictionary = 309 ((attributes & SuggestionsInfo.RESULT_ATTR_IN_THE_DICTIONARY) > 0); 310 final boolean looksLikeTypo = 311 ((attributes & SuggestionsInfo.RESULT_ATTR_LOOKS_LIKE_TYPO) > 0); 312 313 final SpellCheckSpan spellCheckSpan = mSpellCheckSpans[k]; 314 //TODO: we need to change that rule for results from a sentence-level spell 315 // checker that will probably be in dictionary. 316 if (!isInDictionary && looksLikeTypo) { 317 createMisspelledSuggestionSpan( 318 editable, suggestionsInfo, spellCheckSpan, offset, length); 319 } 320 return spellCheckSpan; 321 } 322 } 323 return null; 324 } 325 326 @Override 327 public void onGetSuggestions(SuggestionsInfo[] results) { 328 final Editable editable = (Editable) mTextView.getText(); 329 for (int i = 0; i < results.length; ++i) { 330 final SpellCheckSpan spellCheckSpan = 331 onGetSuggestionsInternal(results[i], USE_SPAN_RANGE, USE_SPAN_RANGE); 332 if (spellCheckSpan != null) { 333 editable.removeSpan(spellCheckSpan); 334 } 335 } 336 scheduleNewSpellCheck(); 337 } 338 339 @Override 340 public void onGetSentenceSuggestions(SentenceSuggestionsInfo[] results) { 341 final Editable editable = (Editable) mTextView.getText(); 342 343 for (int i = 0; i < results.length; ++i) { 344 final SentenceSuggestionsInfo ssi = results[i]; 345 if (ssi == null) { 346 continue; 347 } 348 SpellCheckSpan spellCheckSpan = null; 349 for (int j = 0; j < ssi.getSuggestionsCount(); ++j) { 350 final SuggestionsInfo suggestionsInfo = ssi.getSuggestionsInfoAt(j); 351 if (suggestionsInfo == null) { 352 continue; 353 } 354 final int offset = ssi.getOffsetAt(j); 355 final int length = ssi.getLengthAt(j); 356 final SpellCheckSpan scs = onGetSuggestionsInternal( 357 suggestionsInfo, offset, length); 358 if (spellCheckSpan == null && scs != null) { 359 // the spellCheckSpan is shared by all the "SuggestionsInfo"s in the same 360 // SentenceSuggestionsInfo 361 spellCheckSpan = scs; 362 } 363 } 364 if (spellCheckSpan != null) { 365 editable.removeSpan(spellCheckSpan); 366 } 367 } 368 scheduleNewSpellCheck(); 369 } 370 371 private void scheduleNewSpellCheck() { 372 if (mSpellRunnable == null) { 373 mSpellRunnable = new Runnable() { 374 @Override 375 public void run() { 376 final int length = mSpellParsers.length; 377 for (int i = 0; i < length; i++) { 378 final SpellParser spellParser = mSpellParsers[i]; 379 if (!spellParser.isFinished()) { 380 spellParser.parse(); 381 break; // run one spell parser at a time to bound running time 382 } 383 } 384 } 385 }; 386 } else { 387 mTextView.removeCallbacks(mSpellRunnable); 388 } 389 390 mTextView.postDelayed(mSpellRunnable, SPELL_PAUSE_DURATION); 391 } 392 393 private void createMisspelledSuggestionSpan(Editable editable, SuggestionsInfo suggestionsInfo, 394 SpellCheckSpan spellCheckSpan, int offset, int length) { 395 final int spellCheckSpanStart = editable.getSpanStart(spellCheckSpan); 396 final int spellCheckSpanEnd = editable.getSpanEnd(spellCheckSpan); 397 if (spellCheckSpanStart < 0 || spellCheckSpanEnd <= spellCheckSpanStart) 398 return; // span was removed in the meantime 399 400 final int suggestionsCount = suggestionsInfo.getSuggestionsCount(); 401 if (suggestionsCount <= 0) { 402 // A negative suggestion count is possible 403 return; 404 } 405 406 final int start; 407 final int end; 408 if (offset != USE_SPAN_RANGE && length != USE_SPAN_RANGE) { 409 start = spellCheckSpanStart + offset; 410 end = start + length; 411 } else { 412 start = spellCheckSpanStart; 413 end = spellCheckSpanEnd; 414 } 415 416 String[] suggestions = new String[suggestionsCount]; 417 for (int i = 0; i < suggestionsCount; i++) { 418 suggestions[i] = suggestionsInfo.getSuggestionAt(i); 419 } 420 421 SuggestionSpan suggestionSpan = new SuggestionSpan(mTextView.getContext(), suggestions, 422 SuggestionSpan.FLAG_EASY_CORRECT | SuggestionSpan.FLAG_MISSPELLED); 423 editable.setSpan(suggestionSpan, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 424 425 mTextView.invalidateRegion(start, end, false /* No cursor involved */); 426 } 427 428 private class SpellParser { 429 private Object mRange = new Object(); 430 431 public void parse(int start, int end) { 432 if (end > start) { 433 setRangeSpan((Editable) mTextView.getText(), start, end); 434 parse(); 435 } 436 } 437 438 public boolean isFinished() { 439 return ((Editable) mTextView.getText()).getSpanStart(mRange) < 0; 440 } 441 442 public void stop() { 443 removeRangeSpan((Editable) mTextView.getText()); 444 } 445 446 private void setRangeSpan(Editable editable, int start, int end) { 447 editable.setSpan(mRange, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 448 } 449 450 private void removeRangeSpan(Editable editable) { 451 editable.removeSpan(mRange); 452 } 453 454 public void parse() { 455 Editable editable = (Editable) mTextView.getText(); 456 // Iterate over the newly added text and schedule new SpellCheckSpans 457 final int start = editable.getSpanStart(mRange); 458 final int end = editable.getSpanEnd(mRange); 459 460 int wordIteratorWindowEnd = Math.min(end, start + WORD_ITERATOR_INTERVAL); 461 mWordIterator.setCharSequence(editable, start, wordIteratorWindowEnd); 462 463 // Move back to the beginning of the current word, if any 464 int wordStart = mWordIterator.preceding(start); 465 int wordEnd; 466 if (wordStart == BreakIterator.DONE) { 467 wordEnd = mWordIterator.following(start); 468 if (wordEnd != BreakIterator.DONE) { 469 wordStart = mWordIterator.getBeginning(wordEnd); 470 } 471 } else { 472 wordEnd = mWordIterator.getEnd(wordStart); 473 } 474 if (wordEnd == BreakIterator.DONE) { 475 removeRangeSpan(editable); 476 return; 477 } 478 479 // We need to expand by one character because we want to include the spans that 480 // end/start at position start/end respectively. 481 SpellCheckSpan[] spellCheckSpans = editable.getSpans(start - 1, end + 1, 482 SpellCheckSpan.class); 483 SuggestionSpan[] suggestionSpans = editable.getSpans(start - 1, end + 1, 484 SuggestionSpan.class); 485 486 int wordCount = 0; 487 boolean scheduleOtherSpellCheck = false; 488 489 if (mIsSentenceSpellCheckSupported) { 490 int regionEnd; 491 if (wordIteratorWindowEnd < end) { 492 // Several batches needed on that region. Cut after last previous word 493 regionEnd = mWordIterator.preceding(wordIteratorWindowEnd); 494 scheduleOtherSpellCheck = true; 495 } else { 496 regionEnd = mWordIterator.preceding(end); 497 } 498 boolean correct = regionEnd != BreakIterator.DONE; 499 if (correct) { 500 regionEnd = mWordIterator.getEnd(regionEnd); 501 correct = regionEnd != BreakIterator.DONE; 502 } 503 if (!correct) { 504 editable.removeSpan(mRange); 505 return; 506 } 507 // Stop spell checking when there are no characters in the range. 508 if (wordEnd < start) { 509 return; 510 } 511 wordStart = regionEnd; 512 // TODO: Find the start position of the sentence. 513 // Set span with the context 514 final int spellCheckStart = Math.max( 515 0, Math.min(wordStart, regionEnd - WORD_ITERATOR_INTERVAL)); 516 if (regionEnd <= spellCheckStart) { 517 return; 518 } 519 final int selectionStart = Selection.getSelectionStart(editable); 520 final int selectionEnd = Selection.getSelectionEnd(editable); 521 if (DBG) { 522 Log.d(TAG, "addSpellCheckSpan: " 523 + editable.subSequence(spellCheckStart, regionEnd) 524 + ", regionEnd = " + regionEnd + ", spellCheckStart = " 525 + spellCheckStart + ", sel start = " + selectionStart + ", sel end =" 526 + selectionEnd); 527 } 528 // Do not check this word if the user is currently editing it 529 if (spellCheckStart >= 0 && regionEnd > spellCheckStart 530 && (selectionEnd < spellCheckStart || selectionStart > regionEnd)) { 531 addSpellCheckSpan(editable, spellCheckStart, regionEnd); 532 } 533 } else { 534 while (wordStart <= end) { 535 if (wordEnd >= start && wordEnd > wordStart) { 536 if (wordCount >= MAX_NUMBER_OF_WORDS) { 537 scheduleOtherSpellCheck = true; 538 break; 539 } 540 // A new word has been created across the interval boundaries with this 541 // edit. The previous spans (that ended on start / started on end) are 542 // not valid anymore and must be removed. 543 if (wordStart < start && wordEnd > start) { 544 removeSpansAt(editable, start, spellCheckSpans); 545 removeSpansAt(editable, start, suggestionSpans); 546 } 547 548 if (wordStart < end && wordEnd > end) { 549 removeSpansAt(editable, end, spellCheckSpans); 550 removeSpansAt(editable, end, suggestionSpans); 551 } 552 553 // Do not create new boundary spans if they already exist 554 boolean createSpellCheckSpan = true; 555 if (wordEnd == start) { 556 for (int i = 0; i < spellCheckSpans.length; i++) { 557 final int spanEnd = editable.getSpanEnd(spellCheckSpans[i]); 558 if (spanEnd == start) { 559 createSpellCheckSpan = false; 560 break; 561 } 562 } 563 } 564 565 if (wordStart == end) { 566 for (int i = 0; i < spellCheckSpans.length; i++) { 567 final int spanStart = editable.getSpanStart(spellCheckSpans[i]); 568 if (spanStart == end) { 569 createSpellCheckSpan = false; 570 break; 571 } 572 } 573 } 574 575 if (createSpellCheckSpan) { 576 addSpellCheckSpan(editable, wordStart, wordEnd); 577 } 578 wordCount++; 579 } 580 581 // iterate word by word 582 int originalWordEnd = wordEnd; 583 wordEnd = mWordIterator.following(wordEnd); 584 if ((wordIteratorWindowEnd < end) && 585 (wordEnd == BreakIterator.DONE || wordEnd >= wordIteratorWindowEnd)) { 586 wordIteratorWindowEnd = 587 Math.min(end, originalWordEnd + WORD_ITERATOR_INTERVAL); 588 mWordIterator.setCharSequence( 589 editable, originalWordEnd, wordIteratorWindowEnd); 590 wordEnd = mWordIterator.following(originalWordEnd); 591 } 592 if (wordEnd == BreakIterator.DONE) break; 593 wordStart = mWordIterator.getBeginning(wordEnd); 594 if (wordStart == BreakIterator.DONE) { 595 break; 596 } 597 } 598 } 599 600 if (scheduleOtherSpellCheck) { 601 // Update range span: start new spell check from last wordStart 602 setRangeSpan(editable, wordStart, end); 603 } else { 604 removeRangeSpan(editable); 605 } 606 607 spellCheck(); 608 } 609 610 private <T> void removeSpansAt(Editable editable, int offset, T[] spans) { 611 final int length = spans.length; 612 for (int i = 0; i < length; i++) { 613 final T span = spans[i]; 614 final int start = editable.getSpanStart(span); 615 if (start > offset) continue; 616 final int end = editable.getSpanEnd(span); 617 if (end < offset) continue; 618 editable.removeSpan(span); 619 } 620 } 621 } 622} 623