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 17 package android.widget; 18 19 import android.annotation.Nullable; 20 import android.text.Editable; 21 import android.text.Selection; 22 import android.text.Spanned; 23 import android.text.SpannedString; 24 import android.text.method.WordIterator; 25 import android.text.style.SpellCheckSpan; 26 import android.text.style.SuggestionSpan; 27 import android.util.Log; 28 import android.util.Range; 29 import android.view.textservice.SentenceSuggestionsInfo; 30 import android.view.textservice.SpellCheckerSession; 31 import android.view.textservice.SpellCheckerSession.SpellCheckerSessionListener; 32 import android.view.textservice.SpellCheckerSession.SpellCheckerSessionParams; 33 import android.view.textservice.SuggestionsInfo; 34 import android.view.textservice.TextInfo; 35 import android.view.textservice.TextServicesManager; 36 37 import com.android.internal.util.ArrayUtils; 38 import com.android.internal.util.GrowingArrayUtils; 39 40 import java.text.BreakIterator; 41 import java.util.Locale; 42 43 44 /** 45 * Helper class for TextView. Bridge between the TextView and the Dictionary service. 46 * 47 * @hide 48 */ 49 public class SpellChecker implements SpellCheckerSessionListener { 50 private static final String TAG = SpellChecker.class.getSimpleName(); 51 private static final boolean DBG = false; 52 53 // No more than this number of words will be parsed on each iteration to ensure a minimum 54 // lock of the UI thread 55 public static final int MAX_NUMBER_OF_WORDS = 50; 56 57 // Rough estimate, such that the word iterator interval usually does not need to be shifted 58 public static final int AVERAGE_WORD_LENGTH = 7; 59 60 // When parsing, use a character window of that size. Will be shifted if needed 61 public static final int WORD_ITERATOR_INTERVAL = AVERAGE_WORD_LENGTH * MAX_NUMBER_OF_WORDS; 62 63 // Pause between each spell check to keep the UI smooth 64 private final static int SPELL_PAUSE_DURATION = 400; // milliseconds 65 66 // The maximum length of sentence. 67 private static final int MAX_SENTENCE_LENGTH = WORD_ITERATOR_INTERVAL; 68 69 private static final int USE_SPAN_RANGE = -1; 70 71 private final TextView mTextView; 72 73 SpellCheckerSession mSpellCheckerSession; 74 75 final int mCookie; 76 77 // Paired arrays for the (id, spellCheckSpan) pair. A negative id means the associated 78 // SpellCheckSpan has been recycled and can be-reused. 79 // Contains null SpellCheckSpans after index mLength. 80 private int[] mIds; 81 private SpellCheckSpan[] mSpellCheckSpans; 82 // The mLength first elements of the above arrays have been initialized 83 private int mLength; 84 85 // Parsers on chunk of text, cutting text into words that will be checked 86 private SpellParser[] mSpellParsers = new SpellParser[0]; 87 88 private int mSpanSequenceCounter = 0; 89 90 private Locale mCurrentLocale; 91 92 // Shared by all SpellParsers. Cannot be shared with TextView since it may be used 93 // concurrently due to the asynchronous nature of onGetSuggestions. 94 private SentenceIteratorWrapper mSentenceIterator; 95 96 @Nullable 97 private TextServicesManager mTextServicesManager; 98 99 private Runnable mSpellRunnable; 100 SpellChecker(TextView textView)101 public SpellChecker(TextView textView) { 102 mTextView = textView; 103 104 // Arbitrary: these arrays will automatically double their sizes on demand 105 final int size = 1; 106 mIds = ArrayUtils.newUnpaddedIntArray(size); 107 mSpellCheckSpans = new SpellCheckSpan[mIds.length]; 108 109 setLocale(mTextView.getSpellCheckerLocale()); 110 111 mCookie = hashCode(); 112 } 113 resetSession()114 void resetSession() { 115 closeSession(); 116 117 mTextServicesManager = mTextView.getTextServicesManagerForUser(); 118 if (mCurrentLocale == null 119 || mTextServicesManager == null 120 || mTextView.length() == 0 121 || !mTextServicesManager.isSpellCheckerEnabled() 122 || mTextServicesManager.getCurrentSpellCheckerSubtype(true) == null) { 123 mSpellCheckerSession = null; 124 } else { 125 int supportedAttributes = SuggestionsInfo.RESULT_ATTR_IN_THE_DICTIONARY 126 | SuggestionsInfo.RESULT_ATTR_LOOKS_LIKE_TYPO 127 | SuggestionsInfo.RESULT_ATTR_LOOKS_LIKE_GRAMMAR_ERROR 128 | SuggestionsInfo.RESULT_ATTR_DONT_SHOW_UI_FOR_SUGGESTIONS; 129 SpellCheckerSessionParams params = new SpellCheckerSessionParams.Builder() 130 .setLocale(mCurrentLocale) 131 .setSupportedAttributes(supportedAttributes) 132 .build(); 133 mSpellCheckerSession = mTextServicesManager.newSpellCheckerSession( 134 params, mTextView.getContext().getMainExecutor(), this); 135 } 136 137 // Restore SpellCheckSpans in pool 138 for (int i = 0; i < mLength; i++) { 139 mIds[i] = -1; 140 } 141 mLength = 0; 142 143 // Remove existing misspelled SuggestionSpans 144 mTextView.removeMisspelledSpans((Editable) mTextView.getText()); 145 } 146 setLocale(Locale locale)147 private void setLocale(Locale locale) { 148 mCurrentLocale = locale; 149 150 resetSession(); 151 152 if (locale != null) { 153 // Change SpellParsers' sentenceIterator locale 154 mSentenceIterator = new SentenceIteratorWrapper( 155 BreakIterator.getSentenceInstance(locale)); 156 } 157 158 // This class is the listener for locale change: warn other locale-aware objects 159 mTextView.onLocaleChanged(); 160 } 161 162 /** 163 * @return true if a spell checker session has successfully been created. Returns false if not, 164 * for instance when spell checking has been disabled in settings. 165 */ isSessionActive()166 private boolean isSessionActive() { 167 return mSpellCheckerSession != null; 168 } 169 closeSession()170 public void closeSession() { 171 if (mSpellCheckerSession != null) { 172 mSpellCheckerSession.close(); 173 } 174 175 final int length = mSpellParsers.length; 176 for (int i = 0; i < length; i++) { 177 mSpellParsers[i].stop(); 178 } 179 180 if (mSpellRunnable != null) { 181 mTextView.removeCallbacks(mSpellRunnable); 182 } 183 } 184 nextSpellCheckSpanIndex()185 private int nextSpellCheckSpanIndex() { 186 for (int i = 0; i < mLength; i++) { 187 if (mIds[i] < 0) return i; 188 } 189 190 mIds = GrowingArrayUtils.append(mIds, mLength, 0); 191 mSpellCheckSpans = GrowingArrayUtils.append( 192 mSpellCheckSpans, mLength, new SpellCheckSpan()); 193 mLength++; 194 return mLength - 1; 195 } 196 addSpellCheckSpan(Editable editable, int start, int end)197 private void addSpellCheckSpan(Editable editable, int start, int end) { 198 final int index = nextSpellCheckSpanIndex(); 199 SpellCheckSpan spellCheckSpan = mSpellCheckSpans[index]; 200 editable.setSpan(spellCheckSpan, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 201 spellCheckSpan.setSpellCheckInProgress(false); 202 mIds[index] = mSpanSequenceCounter++; 203 } 204 onSpellCheckSpanRemoved(SpellCheckSpan spellCheckSpan)205 public void onSpellCheckSpanRemoved(SpellCheckSpan spellCheckSpan) { 206 // Recycle any removed SpellCheckSpan (from this code or during text edition) 207 for (int i = 0; i < mLength; i++) { 208 if (mSpellCheckSpans[i] == spellCheckSpan) { 209 mIds[i] = -1; 210 return; 211 } 212 } 213 } 214 onSelectionChanged()215 public void onSelectionChanged() { 216 spellCheck(); 217 } 218 onPerformSpellCheck()219 void onPerformSpellCheck() { 220 // Triggers full content spell check. 221 final int start = 0; 222 final int end = mTextView.length(); 223 if (DBG) { 224 Log.d(TAG, "performSpellCheckAroundSelection: " + start + ", " + end); 225 } 226 spellCheck(start, end, /* forceCheckWhenEditingWord= */ true); 227 } 228 spellCheck(int start, int end)229 public void spellCheck(int start, int end) { 230 spellCheck(start, end, /* forceCheckWhenEditingWord= */ false); 231 } 232 233 /** 234 * Requests to do spell check for text in the range (start, end). 235 */ spellCheck(int start, int end, boolean forceCheckWhenEditingWord)236 public void spellCheck(int start, int end, boolean forceCheckWhenEditingWord) { 237 if (DBG) { 238 Log.d(TAG, "Start spell-checking: " + start + ", " + end + ", " 239 + forceCheckWhenEditingWord); 240 } 241 final Locale locale = mTextView.getSpellCheckerLocale(); 242 final boolean isSessionActive = isSessionActive(); 243 if (locale == null || mCurrentLocale == null || (!(mCurrentLocale.equals(locale)))) { 244 setLocale(locale); 245 // Re-check the entire text 246 start = 0; 247 end = mTextView.getText().length(); 248 } else { 249 final boolean spellCheckerActivated = 250 mTextServicesManager != null && mTextServicesManager.isSpellCheckerEnabled(); 251 if (isSessionActive != spellCheckerActivated) { 252 // Spell checker has been turned of or off since last spellCheck 253 resetSession(); 254 } 255 } 256 257 if (!isSessionActive) return; 258 259 // Find first available SpellParser from pool 260 final int length = mSpellParsers.length; 261 for (int i = 0; i < length; i++) { 262 final SpellParser spellParser = mSpellParsers[i]; 263 if (spellParser.isFinished()) { 264 spellParser.parse(start, end, forceCheckWhenEditingWord); 265 return; 266 } 267 } 268 269 if (DBG) { 270 Log.d(TAG, "new spell parser."); 271 } 272 // No available parser found in pool, create a new one 273 SpellParser[] newSpellParsers = new SpellParser[length + 1]; 274 System.arraycopy(mSpellParsers, 0, newSpellParsers, 0, length); 275 mSpellParsers = newSpellParsers; 276 277 SpellParser spellParser = new SpellParser(); 278 mSpellParsers[length] = spellParser; 279 spellParser.parse(start, end, forceCheckWhenEditingWord); 280 } 281 spellCheck()282 private void spellCheck() { 283 spellCheck(/* forceCheckWhenEditingWord= */ false); 284 } 285 spellCheck(boolean forceCheckWhenEditingWord)286 private void spellCheck(boolean forceCheckWhenEditingWord) { 287 if (mSpellCheckerSession == null) return; 288 289 Editable editable = (Editable) mTextView.getText(); 290 final int selectionStart = Selection.getSelectionStart(editable); 291 final int selectionEnd = Selection.getSelectionEnd(editable); 292 293 TextInfo[] textInfos = new TextInfo[mLength]; 294 int textInfosCount = 0; 295 296 if (DBG) { 297 Log.d(TAG, "forceCheckWhenEditingWord=" + forceCheckWhenEditingWord 298 + ", mLength=" + mLength + ", cookie = " + mCookie 299 + ", sel start = " + selectionStart + ", sel end = " + selectionEnd); 300 } 301 302 for (int i = 0; i < mLength; i++) { 303 final SpellCheckSpan spellCheckSpan = mSpellCheckSpans[i]; 304 if (mIds[i] < 0 || spellCheckSpan.isSpellCheckInProgress()) continue; 305 306 final int start = editable.getSpanStart(spellCheckSpan); 307 final int end = editable.getSpanEnd(spellCheckSpan); 308 309 // Check the span if any of following conditions is met: 310 // - the user is not currently editing it 311 // - or `forceCheckWhenEditingWord` is true. 312 final boolean isNotEditing; 313 314 // Defer spell check when typing a word ending with a punctuation like an apostrophe 315 // which could end up being a mid-word punctuation. 316 if (selectionStart == end + 1 317 && WordIterator.isMidWordPunctuation( 318 mCurrentLocale, Character.codePointBefore(editable, end + 1))) { 319 isNotEditing = false; 320 } else if (selectionEnd <= start || selectionStart > end) { 321 // Allow the overlap of the cursor and the first boundary of the spell check span 322 // no to skip the spell check of the following word because the 323 // following word will never be spell-checked even if the user finishes composing 324 isNotEditing = true; 325 } else { 326 // When cursor is at the end of spell check span, allow spell check if the 327 // character before cursor is a separator. 328 isNotEditing = selectionStart == end 329 && selectionStart > 0 330 && isSeparator(Character.codePointBefore(editable, selectionStart)); 331 } 332 if (start >= 0 && end > start && (forceCheckWhenEditingWord || isNotEditing)) { 333 spellCheckSpan.setSpellCheckInProgress(true); 334 final TextInfo textInfo = new TextInfo(editable, start, end, mCookie, mIds[i]); 335 textInfos[textInfosCount++] = textInfo; 336 if (DBG) { 337 Log.d(TAG, "create TextInfo: (" + i + "/" + mLength + ") text = " 338 + textInfo.getSequence() + ", cookie = " + mCookie + ", seq = " 339 + mIds[i] + ", sel start = " + selectionStart + ", sel end = " 340 + selectionEnd + ", start = " + start + ", end = " + end); 341 } 342 } 343 } 344 345 if (textInfosCount > 0) { 346 if (textInfosCount < textInfos.length) { 347 TextInfo[] textInfosCopy = new TextInfo[textInfosCount]; 348 System.arraycopy(textInfos, 0, textInfosCopy, 0, textInfosCount); 349 textInfos = textInfosCopy; 350 } 351 352 mSpellCheckerSession.getSentenceSuggestions( 353 textInfos, SuggestionSpan.SUGGESTIONS_MAX_SIZE); 354 } 355 } 356 isSeparator(int codepoint)357 private static boolean isSeparator(int codepoint) { 358 final int type = Character.getType(codepoint); 359 return ((1 << type) & ((1 << Character.SPACE_SEPARATOR) 360 | (1 << Character.LINE_SEPARATOR) 361 | (1 << Character.PARAGRAPH_SEPARATOR) 362 | (1 << Character.DASH_PUNCTUATION) 363 | (1 << Character.END_PUNCTUATION) 364 | (1 << Character.FINAL_QUOTE_PUNCTUATION) 365 | (1 << Character.INITIAL_QUOTE_PUNCTUATION) 366 | (1 << Character.START_PUNCTUATION) 367 | (1 << Character.OTHER_PUNCTUATION))) != 0; 368 } 369 onGetSuggestionsInternal( SuggestionsInfo suggestionsInfo, int offset, int length)370 private SpellCheckSpan onGetSuggestionsInternal( 371 SuggestionsInfo suggestionsInfo, int offset, int length) { 372 if (suggestionsInfo == null || suggestionsInfo.getCookie() != mCookie) { 373 return null; 374 } 375 final Editable editable = (Editable) mTextView.getText(); 376 final int sequenceNumber = suggestionsInfo.getSequence(); 377 for (int k = 0; k < mLength; ++k) { 378 if (sequenceNumber == mIds[k]) { 379 final SpellCheckSpan spellCheckSpan = mSpellCheckSpans[k]; 380 final int spellCheckSpanStart = editable.getSpanStart(spellCheckSpan); 381 if (spellCheckSpanStart < 0) { 382 // Skips the suggestion if the matched span has been removed. 383 return null; 384 } 385 386 final int attributes = suggestionsInfo.getSuggestionsAttributes(); 387 final boolean isInDictionary = 388 ((attributes & SuggestionsInfo.RESULT_ATTR_IN_THE_DICTIONARY) > 0); 389 final boolean looksLikeTypo = 390 ((attributes & SuggestionsInfo.RESULT_ATTR_LOOKS_LIKE_TYPO) > 0); 391 final boolean looksLikeGrammarError = 392 ((attributes & SuggestionsInfo.RESULT_ATTR_LOOKS_LIKE_GRAMMAR_ERROR) > 0); 393 394 // Validates the suggestions range in case the SpellCheckSpan is out-of-date but not 395 // removed as expected. 396 if (spellCheckSpanStart + offset + length > editable.length()) { 397 return spellCheckSpan; 398 } 399 //TODO: we need to change that rule for results from a sentence-level spell 400 // checker that will probably be in dictionary. 401 if (!isInDictionary && (looksLikeTypo || looksLikeGrammarError)) { 402 createMisspelledSuggestionSpan( 403 editable, suggestionsInfo, spellCheckSpan, offset, length); 404 } else { 405 // Valid word -- isInDictionary || !looksLikeTypo 406 // Allow the spell checker to remove existing misspelled span by 407 // overwriting the span over the same place 408 final int spellCheckSpanEnd = editable.getSpanEnd(spellCheckSpan); 409 final int start; 410 final int end; 411 if (offset != USE_SPAN_RANGE && length != USE_SPAN_RANGE) { 412 start = spellCheckSpanStart + offset; 413 end = start + length; 414 } else { 415 start = spellCheckSpanStart; 416 end = spellCheckSpanEnd; 417 } 418 if (spellCheckSpanStart >= 0 && spellCheckSpanEnd > spellCheckSpanStart 419 && end > start) { 420 boolean visibleToAccessibility = mTextView.isVisibleToAccessibility(); 421 CharSequence beforeText = 422 visibleToAccessibility ? new SpannedString(editable) : null; 423 boolean spanRemoved = removeErrorSuggestionSpan( 424 editable, start, end, RemoveReason.OBSOLETE); 425 if (visibleToAccessibility && spanRemoved) { 426 mTextView.sendAccessibilityEventTypeViewTextChanged( 427 beforeText, start, end); 428 } 429 } 430 } 431 return spellCheckSpan; 432 } 433 } 434 return null; 435 } 436 437 private enum RemoveReason { 438 /** 439 * Indicates the previous SuggestionSpan is replaced by a new SuggestionSpan. 440 */ 441 REPLACE, 442 /** 443 * Indicates the previous SuggestionSpan is removed because corresponding text is 444 * considered as valid words now. 445 */ 446 OBSOLETE, 447 } 448 removeErrorSuggestionSpan( Editable editable, int start, int end, RemoveReason reason)449 private static boolean removeErrorSuggestionSpan( 450 Editable editable, int start, int end, RemoveReason reason) { 451 boolean spanRemoved = false; 452 SuggestionSpan[] spans = editable.getSpans(start, end, SuggestionSpan.class); 453 for (SuggestionSpan span : spans) { 454 if (editable.getSpanStart(span) == start 455 && editable.getSpanEnd(span) == end 456 && (span.getFlags() & (SuggestionSpan.FLAG_MISSPELLED 457 | SuggestionSpan.FLAG_GRAMMAR_ERROR)) != 0) { 458 if (DBG) { 459 Log.i(TAG, "Remove existing misspelled/grammar error span on " 460 + editable.subSequence(start, end) + ", reason: " + reason); 461 } 462 editable.removeSpan(span); 463 spanRemoved = true; 464 } 465 } 466 return spanRemoved; 467 } 468 469 @Override onGetSuggestions(SuggestionsInfo[] results)470 public void onGetSuggestions(SuggestionsInfo[] results) { 471 final Editable editable = (Editable) mTextView.getText(); 472 for (int i = 0; i < results.length; ++i) { 473 final SpellCheckSpan spellCheckSpan = 474 onGetSuggestionsInternal(results[i], USE_SPAN_RANGE, USE_SPAN_RANGE); 475 if (spellCheckSpan != null) { 476 // onSpellCheckSpanRemoved will recycle this span in the pool 477 editable.removeSpan(spellCheckSpan); 478 } 479 } 480 scheduleNewSpellCheck(); 481 } 482 483 @Override onGetSentenceSuggestions(SentenceSuggestionsInfo[] results)484 public void onGetSentenceSuggestions(SentenceSuggestionsInfo[] results) { 485 final Editable editable = (Editable) mTextView.getText(); 486 for (int i = 0; i < results.length; ++i) { 487 final SentenceSuggestionsInfo ssi = results[i]; 488 if (ssi == null) { 489 continue; 490 } 491 SpellCheckSpan spellCheckSpan = null; 492 for (int j = 0; j < ssi.getSuggestionsCount(); ++j) { 493 final SuggestionsInfo suggestionsInfo = ssi.getSuggestionsInfoAt(j); 494 if (suggestionsInfo == null) { 495 continue; 496 } 497 final int offset = ssi.getOffsetAt(j); 498 final int length = ssi.getLengthAt(j); 499 final SpellCheckSpan scs = onGetSuggestionsInternal( 500 suggestionsInfo, offset, length); 501 if (spellCheckSpan == null && scs != null) { 502 // the spellCheckSpan is shared by all the "SuggestionsInfo"s in the same 503 // SentenceSuggestionsInfo. Removal is deferred after this loop. 504 spellCheckSpan = scs; 505 } 506 } 507 if (spellCheckSpan != null) { 508 // onSpellCheckSpanRemoved will recycle this span in the pool 509 editable.removeSpan(spellCheckSpan); 510 } 511 } 512 scheduleNewSpellCheck(); 513 } 514 scheduleNewSpellCheck()515 private void scheduleNewSpellCheck() { 516 if (DBG) { 517 Log.i(TAG, "schedule new spell check."); 518 } 519 if (mSpellRunnable == null) { 520 mSpellRunnable = new Runnable() { 521 @Override 522 public void run() { 523 final int length = mSpellParsers.length; 524 for (int i = 0; i < length; i++) { 525 final SpellParser spellParser = mSpellParsers[i]; 526 if (!spellParser.isFinished()) { 527 spellParser.parse(); 528 break; // run one spell parser at a time to bound running time 529 } 530 } 531 } 532 }; 533 } else { 534 mTextView.removeCallbacks(mSpellRunnable); 535 } 536 537 mTextView.postDelayed(mSpellRunnable, SPELL_PAUSE_DURATION); 538 } 539 540 // When calling this method, RESULT_ATTR_LOOKS_LIKE_TYPO or RESULT_ATTR_LOOKS_LIKE_GRAMMAR_ERROR 541 // (or both) should be set in suggestionsInfo. createMisspelledSuggestionSpan(Editable editable, SuggestionsInfo suggestionsInfo, SpellCheckSpan spellCheckSpan, int offset, int length)542 private void createMisspelledSuggestionSpan(Editable editable, SuggestionsInfo suggestionsInfo, 543 SpellCheckSpan spellCheckSpan, int offset, int length) { 544 final int spellCheckSpanStart = editable.getSpanStart(spellCheckSpan); 545 final int spellCheckSpanEnd = editable.getSpanEnd(spellCheckSpan); 546 if (spellCheckSpanStart < 0 || spellCheckSpanEnd <= spellCheckSpanStart) 547 return; // span was removed in the meantime 548 549 final int start; 550 final int end; 551 if (offset != USE_SPAN_RANGE && length != USE_SPAN_RANGE) { 552 start = spellCheckSpanStart + offset; 553 end = start + length; 554 } else { 555 start = spellCheckSpanStart; 556 end = spellCheckSpanEnd; 557 } 558 559 final int suggestionsCount = suggestionsInfo.getSuggestionsCount(); 560 String[] suggestions; 561 if (suggestionsCount > 0) { 562 suggestions = new String[suggestionsCount]; 563 for (int i = 0; i < suggestionsCount; i++) { 564 suggestions[i] = suggestionsInfo.getSuggestionAt(i); 565 } 566 } else { 567 suggestions = ArrayUtils.emptyArray(String.class); 568 } 569 570 final int suggestionsAttrs = suggestionsInfo.getSuggestionsAttributes(); 571 int flags = 0; 572 if ((suggestionsAttrs & SuggestionsInfo.RESULT_ATTR_DONT_SHOW_UI_FOR_SUGGESTIONS) == 0) { 573 flags |= SuggestionSpan.FLAG_EASY_CORRECT; 574 } 575 if ((suggestionsAttrs & SuggestionsInfo.RESULT_ATTR_LOOKS_LIKE_TYPO) != 0) { 576 flags |= SuggestionSpan.FLAG_MISSPELLED; 577 } 578 if ((suggestionsAttrs & SuggestionsInfo.RESULT_ATTR_LOOKS_LIKE_GRAMMAR_ERROR) != 0) { 579 flags |= SuggestionSpan.FLAG_GRAMMAR_ERROR; 580 } 581 SuggestionSpan suggestionSpan = 582 new SuggestionSpan(mTextView.getContext(), suggestions, flags); 583 boolean spanRemoved = removeErrorSuggestionSpan(editable, start, end, RemoveReason.REPLACE); 584 boolean sendAccessibilityEvent = !spanRemoved && mTextView.isVisibleToAccessibility(); 585 CharSequence beforeText = sendAccessibilityEvent ? new SpannedString(editable) : null; 586 editable.setSpan(suggestionSpan, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 587 if (sendAccessibilityEvent) { 588 mTextView.sendAccessibilityEventTypeViewTextChanged(beforeText, start, end); 589 } 590 591 mTextView.invalidateRegion(start, end, false /* No cursor involved */); 592 } 593 594 /** 595 * A wrapper of sentence iterator which only processes the specified window of the given text. 596 */ 597 private static class SentenceIteratorWrapper { 598 private BreakIterator mSentenceIterator; 599 private int mStartOffset; 600 private int mEndOffset; 601 SentenceIteratorWrapper(BreakIterator sentenceIterator)602 SentenceIteratorWrapper(BreakIterator sentenceIterator) { 603 mSentenceIterator = sentenceIterator; 604 } 605 606 /** 607 * Set the char sequence and the text window to process. 608 */ setCharSequence(CharSequence sequence, int start, int end)609 public void setCharSequence(CharSequence sequence, int start, int end) { 610 mStartOffset = Math.max(0, start); 611 mEndOffset = Math.min(end, sequence.length()); 612 mSentenceIterator.setText(sequence.subSequence(mStartOffset, mEndOffset).toString()); 613 } 614 615 /** 616 * See {@link BreakIterator#preceding(int)} 617 */ preceding(int offset)618 public int preceding(int offset) { 619 if (offset < mStartOffset) { 620 return BreakIterator.DONE; 621 } 622 int result = mSentenceIterator.preceding(offset - mStartOffset); 623 return result == BreakIterator.DONE ? BreakIterator.DONE : result + mStartOffset; 624 } 625 626 /** 627 * See {@link BreakIterator#following(int)} 628 */ following(int offset)629 public int following(int offset) { 630 if (offset > mEndOffset) { 631 return BreakIterator.DONE; 632 } 633 int result = mSentenceIterator.following(offset - mStartOffset); 634 return result == BreakIterator.DONE ? BreakIterator.DONE : result + mStartOffset; 635 } 636 637 /** 638 * See {@link BreakIterator#isBoundary(int)} 639 */ isBoundary(int offset)640 public boolean isBoundary(int offset) { 641 if (offset < mStartOffset || offset > mEndOffset) { 642 return false; 643 } 644 return mSentenceIterator.isBoundary(offset - mStartOffset); 645 } 646 } 647 648 private class SpellParser { 649 private Object mRange = new Object(); 650 651 // Forces to do spell checker even user is editing the word. 652 private boolean mForceCheckWhenEditingWord; 653 parse(int start, int end, boolean forceCheckWhenEditingWord)654 public void parse(int start, int end, boolean forceCheckWhenEditingWord) { 655 mForceCheckWhenEditingWord = forceCheckWhenEditingWord; 656 final int max = mTextView.length(); 657 final int parseEnd; 658 if (end > max) { 659 Log.w(TAG, "Parse invalid region, from " + start + " to " + end); 660 parseEnd = max; 661 } else { 662 parseEnd = end; 663 } 664 if (parseEnd > start) { 665 setRangeSpan((Editable) mTextView.getText(), start, parseEnd); 666 parse(); 667 } 668 } 669 isFinished()670 public boolean isFinished() { 671 return ((Editable) mTextView.getText()).getSpanStart(mRange) < 0; 672 } 673 stop()674 public void stop() { 675 removeRangeSpan((Editable) mTextView.getText()); 676 mForceCheckWhenEditingWord = false; 677 } 678 setRangeSpan(Editable editable, int start, int end)679 private void setRangeSpan(Editable editable, int start, int end) { 680 if (DBG) { 681 Log.d(TAG, "set next range span: " + start + ", " + end); 682 } 683 editable.setSpan(mRange, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 684 } 685 removeRangeSpan(Editable editable)686 private void removeRangeSpan(Editable editable) { 687 if (DBG) { 688 Log.d(TAG, "Remove range span." + editable.getSpanStart(editable) 689 + editable.getSpanEnd(editable)); 690 } 691 editable.removeSpan(mRange); 692 } 693 parse()694 public void parse() { 695 Editable editable = (Editable) mTextView.getText(); 696 final int textChangeStart = editable.getSpanStart(mRange); 697 final int textChangeEnd = editable.getSpanEnd(mRange); 698 699 Range<Integer> sentenceBoundary = detectSentenceBoundary(editable, textChangeStart, 700 textChangeEnd); 701 int sentenceStart = sentenceBoundary.getLower(); 702 int sentenceEnd = sentenceBoundary.getUpper(); 703 704 if (sentenceStart == sentenceEnd) { 705 if (DBG) { 706 Log.i(TAG, "No more spell check."); 707 } 708 stop(); 709 return; 710 } 711 712 boolean scheduleOtherSpellCheck = false; 713 714 if (sentenceEnd < textChangeEnd) { 715 if (DBG) { 716 Log.i(TAG, "schedule other spell check."); 717 } 718 // Several batches needed on that region. Cut after last previous word 719 scheduleOtherSpellCheck = true; 720 } 721 int spellCheckEnd = sentenceEnd; 722 do { 723 int spellCheckStart = sentenceStart; 724 boolean createSpellCheckSpan = true; 725 // Cancel or merge overlapped spell check spans 726 for (int i = 0; i < mLength; ++i) { 727 final SpellCheckSpan spellCheckSpan = mSpellCheckSpans[i]; 728 if (mIds[i] < 0 || spellCheckSpan.isSpellCheckInProgress()) { 729 continue; 730 } 731 final int spanStart = editable.getSpanStart(spellCheckSpan); 732 final int spanEnd = editable.getSpanEnd(spellCheckSpan); 733 if (spanEnd < spellCheckStart || spellCheckEnd < spanStart) { 734 // No need to merge 735 continue; 736 } 737 if (spanStart <= spellCheckStart && spellCheckEnd <= spanEnd) { 738 // There is a completely overlapped spell check span 739 // skip this span 740 createSpellCheckSpan = false; 741 if (DBG) { 742 Log.i(TAG, "The range is overrapped. Skip spell check."); 743 } 744 break; 745 } 746 // This spellCheckSpan is replaced by the one we are creating 747 editable.removeSpan(spellCheckSpan); 748 spellCheckStart = Math.min(spanStart, spellCheckStart); 749 spellCheckEnd = Math.max(spanEnd, spellCheckEnd); 750 } 751 752 if (DBG) { 753 Log.d(TAG, "addSpellCheckSpan: " 754 + ", End = " + spellCheckEnd + ", Start = " + spellCheckStart 755 + ", next = " + scheduleOtherSpellCheck + "\n" 756 + editable.subSequence(spellCheckStart, spellCheckEnd)); 757 } 758 759 // Stop spell checking when there are no characters in the range. 760 if (spellCheckEnd <= spellCheckStart) { 761 Log.w(TAG, "Trying to spellcheck invalid region, from " 762 + sentenceStart + " to " + spellCheckEnd); 763 break; 764 } 765 if (createSpellCheckSpan) { 766 addSpellCheckSpan(editable, spellCheckStart, spellCheckEnd); 767 } 768 } while (false); 769 sentenceStart = spellCheckEnd; 770 if (scheduleOtherSpellCheck && sentenceStart != BreakIterator.DONE 771 && sentenceStart <= textChangeEnd) { 772 // Update range span: start new spell check from last wordStart 773 setRangeSpan(editable, sentenceStart, textChangeEnd); 774 } else { 775 removeRangeSpan(editable); 776 } 777 spellCheck(mForceCheckWhenEditingWord); 778 } 779 removeSpansAt(Editable editable, int offset, T[] spans)780 private <T> void removeSpansAt(Editable editable, int offset, T[] spans) { 781 final int length = spans.length; 782 for (int i = 0; i < length; i++) { 783 final T span = spans[i]; 784 final int start = editable.getSpanStart(span); 785 if (start > offset) continue; 786 final int end = editable.getSpanEnd(span); 787 if (end < offset) continue; 788 editable.removeSpan(span); 789 } 790 } 791 } 792 detectSentenceBoundary(CharSequence sequence, int textChangeStart, int textChangeEnd)793 private Range<Integer> detectSentenceBoundary(CharSequence sequence, 794 int textChangeStart, int textChangeEnd) { 795 // Only process a substring of the full text due to performance concern. 796 final int iteratorWindowStart = findSeparator(sequence, 797 Math.max(0, textChangeStart - MAX_SENTENCE_LENGTH), 798 Math.max(0, textChangeStart - 2 * MAX_SENTENCE_LENGTH)); 799 final int iteratorWindowEnd = findSeparator(sequence, 800 Math.min(textChangeStart + 2 * MAX_SENTENCE_LENGTH, textChangeEnd), 801 Math.min(textChangeStart + 3 * MAX_SENTENCE_LENGTH, sequence.length())); 802 if (DBG) { 803 Log.d(TAG, "Set iterator window as [" + iteratorWindowStart + ", " + iteratorWindowEnd 804 + ")."); 805 } 806 mSentenceIterator.setCharSequence(sequence, iteratorWindowStart, iteratorWindowEnd); 807 808 // Detect the offset of sentence begin/end on the substring. 809 int sentenceStart = mSentenceIterator.isBoundary(textChangeStart) ? textChangeStart 810 : mSentenceIterator.preceding(textChangeStart); 811 int sentenceEnd = mSentenceIterator.following(sentenceStart); 812 if (sentenceEnd == BreakIterator.DONE) { 813 sentenceEnd = iteratorWindowEnd; 814 } 815 if (DBG) { 816 if (sentenceStart != sentenceEnd) { 817 Log.d(TAG, "Sentence detected [" + sentenceStart + ", " + sentenceEnd + ")."); 818 } 819 } 820 821 if (sentenceEnd - sentenceStart <= MAX_SENTENCE_LENGTH) { 822 // Add more sentences until the MAX_SENTENCE_LENGTH limitation is reached. 823 while (sentenceEnd < textChangeEnd) { 824 int nextEnd = mSentenceIterator.following(sentenceEnd); 825 if (nextEnd == BreakIterator.DONE 826 || nextEnd - sentenceStart > MAX_SENTENCE_LENGTH) { 827 break; 828 } 829 sentenceEnd = nextEnd; 830 } 831 } else { 832 // If the sentence containing `textChangeStart` is longer than MAX_SENTENCE_LENGTH, 833 // the sentence will be sliced into sub-sentences of about MAX_SENTENCE_LENGTH 834 // characters each. This is done by processing the unchecked part of that sentence : 835 // [textChangeStart, sentenceEnd) 836 // 837 // - If the `uncheckedLength` is bigger than MAX_SENTENCE_LENGTH, then check the 838 // [textChangeStart, textChangeStart + MAX_SENTENCE_LENGTH), and leave the rest 839 // part for the next check. 840 // 841 // - If the `uncheckedLength` is smaller than or equal to MAX_SENTENCE_LENGTH, 842 // then check [sentenceEnd - MAX_SENTENCE_LENGTH, sentenceEnd). 843 // 844 // The offset should be rounded up to word boundary. 845 int uncheckedLength = sentenceEnd - textChangeStart; 846 if (uncheckedLength > MAX_SENTENCE_LENGTH) { 847 sentenceEnd = findSeparator(sequence, textChangeStart + MAX_SENTENCE_LENGTH, 848 sentenceEnd); 849 sentenceStart = roundUpToWordStart(sequence, textChangeStart, sentenceStart); 850 } else { 851 sentenceStart = roundUpToWordStart(sequence, sentenceEnd - MAX_SENTENCE_LENGTH, 852 sentenceStart); 853 } 854 } 855 return new Range<>(sentenceStart, Math.max(sentenceStart, sentenceEnd)); 856 } 857 roundUpToWordStart(CharSequence sequence, int position, int frontBoundary)858 private int roundUpToWordStart(CharSequence sequence, int position, int frontBoundary) { 859 if (isSeparator(sequence.charAt(position))) { 860 return position; 861 } 862 int separator = findSeparator(sequence, position, frontBoundary); 863 return separator != frontBoundary ? separator + 1 : frontBoundary; 864 } 865 866 /** 867 * Search the range [start, end) of sequence and returns the position of the first separator. 868 * If end is smaller than start, do a reverse search. 869 * Returns `end` if no separator is found. 870 */ findSeparator(CharSequence sequence, int start, int end)871 private static int findSeparator(CharSequence sequence, int start, int end) { 872 final int step = start < end ? 1 : -1; 873 for (int i = start; i != end; i += step) { 874 if (isSeparator(sequence.charAt(i))) { 875 return i; 876 } 877 } 878 return end; 879 } 880 881 public static boolean haveWordBoundariesChanged(final Editable editable, final int start, 882 final int end, final int spanStart, final int spanEnd) { 883 final boolean haveWordBoundariesChanged; 884 if (spanEnd != start && spanStart != end) { 885 haveWordBoundariesChanged = true; 886 if (DBG) { 887 Log.d(TAG, "(1) Text inside the span has been modified. Remove."); 888 } 889 } else if (spanEnd == start && start < editable.length()) { 890 final int codePoint = Character.codePointAt(editable, start); 891 haveWordBoundariesChanged = Character.isLetterOrDigit(codePoint); 892 if (DBG) { 893 Log.d(TAG, "(2) Characters have been appended to the spanned text. " 894 + (haveWordBoundariesChanged ? "Remove.<" : "Keep. <") + (char)(codePoint) 895 + ">, " + editable + ", " + editable.subSequence(spanStart, spanEnd) + ", " 896 + start); 897 } 898 } else if (spanStart == end && end > 0) { 899 final int codePoint = Character.codePointBefore(editable, end); 900 haveWordBoundariesChanged = Character.isLetterOrDigit(codePoint); 901 if (DBG) { 902 Log.d(TAG, "(3) Characters have been prepended to the spanned text. " 903 + (haveWordBoundariesChanged ? "Remove.<" : "Keep.<") + (char)(codePoint) 904 + ">, " + editable + ", " + editable.subSequence(spanStart, spanEnd) + ", " 905 + end); 906 } 907 } else { 908 if (DBG) { 909 Log.d(TAG, "(4) Characters adjacent to the spanned text were deleted. Keep."); 910 } 911 haveWordBoundariesChanged = false; 912 } 913 return haveWordBoundariesChanged; 914 } 915 } 916