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