/* * Copyright (C) 2011 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package android.widget; import android.annotation.Nullable; import android.text.Editable; import android.text.Selection; import android.text.Spanned; import android.text.SpannedString; import android.text.method.WordIterator; import android.text.style.SpellCheckSpan; import android.text.style.SuggestionSpan; import android.util.Log; import android.util.Range; import android.view.textservice.SentenceSuggestionsInfo; import android.view.textservice.SpellCheckerSession; import android.view.textservice.SpellCheckerSession.SpellCheckerSessionListener; import android.view.textservice.SpellCheckerSession.SpellCheckerSessionParams; import android.view.textservice.SuggestionsInfo; import android.view.textservice.TextInfo; import android.view.textservice.TextServicesManager; import com.android.internal.util.ArrayUtils; import com.android.internal.util.GrowingArrayUtils; import java.text.BreakIterator; import java.util.Locale; /** * Helper class for TextView. Bridge between the TextView and the Dictionary service. * * @hide */ public class SpellChecker implements SpellCheckerSessionListener { private static final String TAG = SpellChecker.class.getSimpleName(); private static final boolean DBG = false; // No more than this number of words will be parsed on each iteration to ensure a minimum // lock of the UI thread public static final int MAX_NUMBER_OF_WORDS = 50; // Rough estimate, such that the word iterator interval usually does not need to be shifted public static final int AVERAGE_WORD_LENGTH = 7; // When parsing, use a character window of that size. Will be shifted if needed public static final int WORD_ITERATOR_INTERVAL = AVERAGE_WORD_LENGTH * MAX_NUMBER_OF_WORDS; // Pause between each spell check to keep the UI smooth private final static int SPELL_PAUSE_DURATION = 400; // milliseconds // The maximum length of sentence. private static final int MAX_SENTENCE_LENGTH = WORD_ITERATOR_INTERVAL; private static final int USE_SPAN_RANGE = -1; private final TextView mTextView; SpellCheckerSession mSpellCheckerSession; final int mCookie; // Paired arrays for the (id, spellCheckSpan) pair. A negative id means the associated // SpellCheckSpan has been recycled and can be-reused. // Contains null SpellCheckSpans after index mLength. private int[] mIds; private SpellCheckSpan[] mSpellCheckSpans; // The mLength first elements of the above arrays have been initialized private int mLength; // Parsers on chunk of text, cutting text into words that will be checked private SpellParser[] mSpellParsers = new SpellParser[0]; private int mSpanSequenceCounter = 0; private Locale mCurrentLocale; // Shared by all SpellParsers. Cannot be shared with TextView since it may be used // concurrently due to the asynchronous nature of onGetSuggestions. private SentenceIteratorWrapper mSentenceIterator; @Nullable private TextServicesManager mTextServicesManager; private Runnable mSpellRunnable; public SpellChecker(TextView textView) { mTextView = textView; // Arbitrary: these arrays will automatically double their sizes on demand final int size = 1; mIds = ArrayUtils.newUnpaddedIntArray(size); mSpellCheckSpans = new SpellCheckSpan[mIds.length]; setLocale(mTextView.getSpellCheckerLocale()); mCookie = hashCode(); } void resetSession() { closeSession(); mTextServicesManager = mTextView.getTextServicesManagerForUser(); if (mCurrentLocale == null || mTextServicesManager == null || mTextView.length() == 0 || !mTextServicesManager.isSpellCheckerEnabled() || mTextServicesManager.getCurrentSpellCheckerSubtype(true) == null) { mSpellCheckerSession = null; } else { int supportedAttributes = SuggestionsInfo.RESULT_ATTR_IN_THE_DICTIONARY | SuggestionsInfo.RESULT_ATTR_LOOKS_LIKE_TYPO | SuggestionsInfo.RESULT_ATTR_LOOKS_LIKE_GRAMMAR_ERROR | SuggestionsInfo.RESULT_ATTR_DONT_SHOW_UI_FOR_SUGGESTIONS; SpellCheckerSessionParams params = new SpellCheckerSessionParams.Builder() .setLocale(mCurrentLocale) .setSupportedAttributes(supportedAttributes) .build(); mSpellCheckerSession = mTextServicesManager.newSpellCheckerSession( params, mTextView.getContext().getMainExecutor(), this); } // Restore SpellCheckSpans in pool for (int i = 0; i < mLength; i++) { mIds[i] = -1; } mLength = 0; // Remove existing misspelled SuggestionSpans mTextView.removeMisspelledSpans((Editable) mTextView.getText()); } private void setLocale(Locale locale) { mCurrentLocale = locale; resetSession(); if (locale != null) { // Change SpellParsers' sentenceIterator locale mSentenceIterator = new SentenceIteratorWrapper( BreakIterator.getSentenceInstance(locale)); } // This class is the listener for locale change: warn other locale-aware objects mTextView.onLocaleChanged(); } /** * @return true if a spell checker session has successfully been created. Returns false if not, * for instance when spell checking has been disabled in settings. */ private boolean isSessionActive() { return mSpellCheckerSession != null; } public void closeSession() { if (mSpellCheckerSession != null) { mSpellCheckerSession.close(); } final int length = mSpellParsers.length; for (int i = 0; i < length; i++) { mSpellParsers[i].stop(); } if (mSpellRunnable != null) { mTextView.removeCallbacks(mSpellRunnable); } } private int nextSpellCheckSpanIndex() { for (int i = 0; i < mLength; i++) { if (mIds[i] < 0) return i; } mIds = GrowingArrayUtils.append(mIds, mLength, 0); mSpellCheckSpans = GrowingArrayUtils.append( mSpellCheckSpans, mLength, new SpellCheckSpan()); mLength++; return mLength - 1; } private void addSpellCheckSpan(Editable editable, int start, int end) { final int index = nextSpellCheckSpanIndex(); SpellCheckSpan spellCheckSpan = mSpellCheckSpans[index]; editable.setSpan(spellCheckSpan, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); spellCheckSpan.setSpellCheckInProgress(false); mIds[index] = mSpanSequenceCounter++; } public void onSpellCheckSpanRemoved(SpellCheckSpan spellCheckSpan) { // Recycle any removed SpellCheckSpan (from this code or during text edition) for (int i = 0; i < mLength; i++) { if (mSpellCheckSpans[i] == spellCheckSpan) { mIds[i] = -1; return; } } } public void onSelectionChanged() { spellCheck(); } void onPerformSpellCheck() { // Triggers full content spell check. final int start = 0; final int end = mTextView.length(); if (DBG) { Log.d(TAG, "performSpellCheckAroundSelection: " + start + ", " + end); } spellCheck(start, end, /* forceCheckWhenEditingWord= */ true); } public void spellCheck(int start, int end) { spellCheck(start, end, /* forceCheckWhenEditingWord= */ false); } /** * Requests to do spell check for text in the range (start, end). */ public void spellCheck(int start, int end, boolean forceCheckWhenEditingWord) { if (DBG) { Log.d(TAG, "Start spell-checking: " + start + ", " + end + ", " + forceCheckWhenEditingWord); } final Locale locale = mTextView.getSpellCheckerLocale(); final boolean isSessionActive = isSessionActive(); if (locale == null || mCurrentLocale == null || (!(mCurrentLocale.equals(locale)))) { setLocale(locale); // Re-check the entire text start = 0; end = mTextView.getText().length(); } else { final boolean spellCheckerActivated = mTextServicesManager != null && mTextServicesManager.isSpellCheckerEnabled(); if (isSessionActive != spellCheckerActivated) { // Spell checker has been turned of or off since last spellCheck resetSession(); } } if (!isSessionActive) return; // Find first available SpellParser from pool final int length = mSpellParsers.length; for (int i = 0; i < length; i++) { final SpellParser spellParser = mSpellParsers[i]; if (spellParser.isFinished()) { spellParser.parse(start, end, forceCheckWhenEditingWord); return; } } if (DBG) { Log.d(TAG, "new spell parser."); } // No available parser found in pool, create a new one SpellParser[] newSpellParsers = new SpellParser[length + 1]; System.arraycopy(mSpellParsers, 0, newSpellParsers, 0, length); mSpellParsers = newSpellParsers; SpellParser spellParser = new SpellParser(); mSpellParsers[length] = spellParser; spellParser.parse(start, end, forceCheckWhenEditingWord); } private void spellCheck() { spellCheck(/* forceCheckWhenEditingWord= */ false); } private void spellCheck(boolean forceCheckWhenEditingWord) { if (mSpellCheckerSession == null) return; Editable editable = (Editable) mTextView.getText(); final int selectionStart = Selection.getSelectionStart(editable); final int selectionEnd = Selection.getSelectionEnd(editable); TextInfo[] textInfos = new TextInfo[mLength]; int textInfosCount = 0; if (DBG) { Log.d(TAG, "forceCheckWhenEditingWord=" + forceCheckWhenEditingWord + ", mLength=" + mLength + ", cookie = " + mCookie + ", sel start = " + selectionStart + ", sel end = " + selectionEnd); } for (int i = 0; i < mLength; i++) { final SpellCheckSpan spellCheckSpan = mSpellCheckSpans[i]; if (mIds[i] < 0 || spellCheckSpan.isSpellCheckInProgress()) continue; final int start = editable.getSpanStart(spellCheckSpan); final int end = editable.getSpanEnd(spellCheckSpan); // Check the span if any of following conditions is met: // - the user is not currently editing it // - or `forceCheckWhenEditingWord` is true. final boolean isNotEditing; // Defer spell check when typing a word ending with a punctuation like an apostrophe // which could end up being a mid-word punctuation. if (selectionStart == end + 1 && WordIterator.isMidWordPunctuation( mCurrentLocale, Character.codePointBefore(editable, end + 1))) { isNotEditing = false; } else if (selectionEnd <= start || selectionStart > end) { // Allow the overlap of the cursor and the first boundary of the spell check span // no to skip the spell check of the following word because the // following word will never be spell-checked even if the user finishes composing isNotEditing = true; } else { // When cursor is at the end of spell check span, allow spell check if the // character before cursor is a separator. isNotEditing = selectionStart == end && selectionStart > 0 && isSeparator(Character.codePointBefore(editable, selectionStart)); } if (start >= 0 && end > start && (forceCheckWhenEditingWord || isNotEditing)) { spellCheckSpan.setSpellCheckInProgress(true); final TextInfo textInfo = new TextInfo(editable, start, end, mCookie, mIds[i]); textInfos[textInfosCount++] = textInfo; if (DBG) { Log.d(TAG, "create TextInfo: (" + i + "/" + mLength + ") text = " + textInfo.getSequence() + ", cookie = " + mCookie + ", seq = " + mIds[i] + ", sel start = " + selectionStart + ", sel end = " + selectionEnd + ", start = " + start + ", end = " + end); } } } if (textInfosCount > 0) { if (textInfosCount < textInfos.length) { TextInfo[] textInfosCopy = new TextInfo[textInfosCount]; System.arraycopy(textInfos, 0, textInfosCopy, 0, textInfosCount); textInfos = textInfosCopy; } mSpellCheckerSession.getSentenceSuggestions( textInfos, SuggestionSpan.SUGGESTIONS_MAX_SIZE); } } private static boolean isSeparator(int codepoint) { final int type = Character.getType(codepoint); return ((1 << type) & ((1 << Character.SPACE_SEPARATOR) | (1 << Character.LINE_SEPARATOR) | (1 << Character.PARAGRAPH_SEPARATOR) | (1 << Character.DASH_PUNCTUATION) | (1 << Character.END_PUNCTUATION) | (1 << Character.FINAL_QUOTE_PUNCTUATION) | (1 << Character.INITIAL_QUOTE_PUNCTUATION) | (1 << Character.START_PUNCTUATION) | (1 << Character.OTHER_PUNCTUATION))) != 0; } private SpellCheckSpan onGetSuggestionsInternal( SuggestionsInfo suggestionsInfo, int offset, int length) { if (suggestionsInfo == null || suggestionsInfo.getCookie() != mCookie) { return null; } final Editable editable = (Editable) mTextView.getText(); final int sequenceNumber = suggestionsInfo.getSequence(); for (int k = 0; k < mLength; ++k) { if (sequenceNumber == mIds[k]) { final SpellCheckSpan spellCheckSpan = mSpellCheckSpans[k]; final int spellCheckSpanStart = editable.getSpanStart(spellCheckSpan); if (spellCheckSpanStart < 0) { // Skips the suggestion if the matched span has been removed. return null; } final int attributes = suggestionsInfo.getSuggestionsAttributes(); final boolean isInDictionary = ((attributes & SuggestionsInfo.RESULT_ATTR_IN_THE_DICTIONARY) > 0); final boolean looksLikeTypo = ((attributes & SuggestionsInfo.RESULT_ATTR_LOOKS_LIKE_TYPO) > 0); final boolean looksLikeGrammarError = ((attributes & SuggestionsInfo.RESULT_ATTR_LOOKS_LIKE_GRAMMAR_ERROR) > 0); // Validates the suggestions range in case the SpellCheckSpan is out-of-date but not // removed as expected. if (spellCheckSpanStart + offset + length > editable.length()) { return spellCheckSpan; } //TODO: we need to change that rule for results from a sentence-level spell // checker that will probably be in dictionary. if (!isInDictionary && (looksLikeTypo || looksLikeGrammarError)) { createMisspelledSuggestionSpan( editable, suggestionsInfo, spellCheckSpan, offset, length); } else { // Valid word -- isInDictionary || !looksLikeTypo // Allow the spell checker to remove existing misspelled span by // overwriting the span over the same place final int spellCheckSpanEnd = editable.getSpanEnd(spellCheckSpan); final int start; final int end; if (offset != USE_SPAN_RANGE && length != USE_SPAN_RANGE) { start = spellCheckSpanStart + offset; end = start + length; } else { start = spellCheckSpanStart; end = spellCheckSpanEnd; } if (spellCheckSpanStart >= 0 && spellCheckSpanEnd > spellCheckSpanStart && end > start) { boolean visibleToAccessibility = mTextView.isVisibleToAccessibility(); CharSequence beforeText = visibleToAccessibility ? new SpannedString(editable) : null; boolean spanRemoved = removeErrorSuggestionSpan( editable, start, end, RemoveReason.OBSOLETE); if (visibleToAccessibility && spanRemoved) { mTextView.sendAccessibilityEventTypeViewTextChanged( beforeText, start, end); } } } return spellCheckSpan; } } return null; } private enum RemoveReason { /** * Indicates the previous SuggestionSpan is replaced by a new SuggestionSpan. */ REPLACE, /** * Indicates the previous SuggestionSpan is removed because corresponding text is * considered as valid words now. */ OBSOLETE, } private static boolean removeErrorSuggestionSpan( Editable editable, int start, int end, RemoveReason reason) { boolean spanRemoved = false; SuggestionSpan[] spans = editable.getSpans(start, end, SuggestionSpan.class); for (SuggestionSpan span : spans) { if (editable.getSpanStart(span) == start && editable.getSpanEnd(span) == end && (span.getFlags() & (SuggestionSpan.FLAG_MISSPELLED | SuggestionSpan.FLAG_GRAMMAR_ERROR)) != 0) { if (DBG) { Log.i(TAG, "Remove existing misspelled/grammar error span on " + editable.subSequence(start, end) + ", reason: " + reason); } editable.removeSpan(span); spanRemoved = true; } } return spanRemoved; } @Override public void onGetSuggestions(SuggestionsInfo[] results) { final Editable editable = (Editable) mTextView.getText(); for (int i = 0; i < results.length; ++i) { final SpellCheckSpan spellCheckSpan = onGetSuggestionsInternal(results[i], USE_SPAN_RANGE, USE_SPAN_RANGE); if (spellCheckSpan != null) { // onSpellCheckSpanRemoved will recycle this span in the pool editable.removeSpan(spellCheckSpan); } } scheduleNewSpellCheck(); } @Override public void onGetSentenceSuggestions(SentenceSuggestionsInfo[] results) { final Editable editable = (Editable) mTextView.getText(); for (int i = 0; i < results.length; ++i) { final SentenceSuggestionsInfo ssi = results[i]; if (ssi == null) { continue; } SpellCheckSpan spellCheckSpan = null; for (int j = 0; j < ssi.getSuggestionsCount(); ++j) { final SuggestionsInfo suggestionsInfo = ssi.getSuggestionsInfoAt(j); if (suggestionsInfo == null) { continue; } final int offset = ssi.getOffsetAt(j); final int length = ssi.getLengthAt(j); final SpellCheckSpan scs = onGetSuggestionsInternal( suggestionsInfo, offset, length); if (spellCheckSpan == null && scs != null) { // the spellCheckSpan is shared by all the "SuggestionsInfo"s in the same // SentenceSuggestionsInfo. Removal is deferred after this loop. spellCheckSpan = scs; } } if (spellCheckSpan != null) { // onSpellCheckSpanRemoved will recycle this span in the pool editable.removeSpan(spellCheckSpan); } } scheduleNewSpellCheck(); } private void scheduleNewSpellCheck() { if (DBG) { Log.i(TAG, "schedule new spell check."); } if (mSpellRunnable == null) { mSpellRunnable = new Runnable() { @Override public void run() { final int length = mSpellParsers.length; for (int i = 0; i < length; i++) { final SpellParser spellParser = mSpellParsers[i]; if (!spellParser.isFinished()) { spellParser.parse(); break; // run one spell parser at a time to bound running time } } } }; } else { mTextView.removeCallbacks(mSpellRunnable); } mTextView.postDelayed(mSpellRunnable, SPELL_PAUSE_DURATION); } // When calling this method, RESULT_ATTR_LOOKS_LIKE_TYPO or RESULT_ATTR_LOOKS_LIKE_GRAMMAR_ERROR // (or both) should be set in suggestionsInfo. private void createMisspelledSuggestionSpan(Editable editable, SuggestionsInfo suggestionsInfo, SpellCheckSpan spellCheckSpan, int offset, int length) { final int spellCheckSpanStart = editable.getSpanStart(spellCheckSpan); final int spellCheckSpanEnd = editable.getSpanEnd(spellCheckSpan); if (spellCheckSpanStart < 0 || spellCheckSpanEnd <= spellCheckSpanStart) return; // span was removed in the meantime final int start; final int end; if (offset != USE_SPAN_RANGE && length != USE_SPAN_RANGE) { start = spellCheckSpanStart + offset; end = start + length; } else { start = spellCheckSpanStart; end = spellCheckSpanEnd; } final int suggestionsCount = suggestionsInfo.getSuggestionsCount(); String[] suggestions; if (suggestionsCount > 0) { suggestions = new String[suggestionsCount]; for (int i = 0; i < suggestionsCount; i++) { suggestions[i] = suggestionsInfo.getSuggestionAt(i); } } else { suggestions = ArrayUtils.emptyArray(String.class); } final int suggestionsAttrs = suggestionsInfo.getSuggestionsAttributes(); int flags = 0; if ((suggestionsAttrs & SuggestionsInfo.RESULT_ATTR_DONT_SHOW_UI_FOR_SUGGESTIONS) == 0) { flags |= SuggestionSpan.FLAG_EASY_CORRECT; } if ((suggestionsAttrs & SuggestionsInfo.RESULT_ATTR_LOOKS_LIKE_TYPO) != 0) { flags |= SuggestionSpan.FLAG_MISSPELLED; } if ((suggestionsAttrs & SuggestionsInfo.RESULT_ATTR_LOOKS_LIKE_GRAMMAR_ERROR) != 0) { flags |= SuggestionSpan.FLAG_GRAMMAR_ERROR; } SuggestionSpan suggestionSpan = new SuggestionSpan(mTextView.getContext(), suggestions, flags); boolean spanRemoved = removeErrorSuggestionSpan(editable, start, end, RemoveReason.REPLACE); boolean sendAccessibilityEvent = !spanRemoved && mTextView.isVisibleToAccessibility(); CharSequence beforeText = sendAccessibilityEvent ? new SpannedString(editable) : null; editable.setSpan(suggestionSpan, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); if (sendAccessibilityEvent) { mTextView.sendAccessibilityEventTypeViewTextChanged(beforeText, start, end); } mTextView.invalidateRegion(start, end, false /* No cursor involved */); } /** * A wrapper of sentence iterator which only processes the specified window of the given text. */ private static class SentenceIteratorWrapper { private BreakIterator mSentenceIterator; private int mStartOffset; private int mEndOffset; SentenceIteratorWrapper(BreakIterator sentenceIterator) { mSentenceIterator = sentenceIterator; } /** * Set the char sequence and the text window to process. */ public void setCharSequence(CharSequence sequence, int start, int end) { mStartOffset = Math.max(0, start); mEndOffset = Math.min(end, sequence.length()); mSentenceIterator.setText(sequence.subSequence(mStartOffset, mEndOffset).toString()); } /** * See {@link BreakIterator#preceding(int)} */ public int preceding(int offset) { if (offset < mStartOffset) { return BreakIterator.DONE; } int result = mSentenceIterator.preceding(offset - mStartOffset); return result == BreakIterator.DONE ? BreakIterator.DONE : result + mStartOffset; } /** * See {@link BreakIterator#following(int)} */ public int following(int offset) { if (offset > mEndOffset) { return BreakIterator.DONE; } int result = mSentenceIterator.following(offset - mStartOffset); return result == BreakIterator.DONE ? BreakIterator.DONE : result + mStartOffset; } /** * See {@link BreakIterator#isBoundary(int)} */ public boolean isBoundary(int offset) { if (offset < mStartOffset || offset > mEndOffset) { return false; } return mSentenceIterator.isBoundary(offset - mStartOffset); } } private class SpellParser { private Object mRange = new Object(); // Forces to do spell checker even user is editing the word. private boolean mForceCheckWhenEditingWord; public void parse(int start, int end, boolean forceCheckWhenEditingWord) { mForceCheckWhenEditingWord = forceCheckWhenEditingWord; final int max = mTextView.length(); final int parseEnd; if (end > max) { Log.w(TAG, "Parse invalid region, from " + start + " to " + end); parseEnd = max; } else { parseEnd = end; } if (parseEnd > start) { setRangeSpan((Editable) mTextView.getText(), start, parseEnd); parse(); } } public boolean isFinished() { return ((Editable) mTextView.getText()).getSpanStart(mRange) < 0; } public void stop() { removeRangeSpan((Editable) mTextView.getText()); mForceCheckWhenEditingWord = false; } private void setRangeSpan(Editable editable, int start, int end) { if (DBG) { Log.d(TAG, "set next range span: " + start + ", " + end); } editable.setSpan(mRange, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); } private void removeRangeSpan(Editable editable) { if (DBG) { Log.d(TAG, "Remove range span." + editable.getSpanStart(editable) + editable.getSpanEnd(editable)); } editable.removeSpan(mRange); } public void parse() { Editable editable = (Editable) mTextView.getText(); final int textChangeStart = editable.getSpanStart(mRange); final int textChangeEnd = editable.getSpanEnd(mRange); Range sentenceBoundary = detectSentenceBoundary(editable, textChangeStart, textChangeEnd); int sentenceStart = sentenceBoundary.getLower(); int sentenceEnd = sentenceBoundary.getUpper(); if (sentenceStart == sentenceEnd) { if (DBG) { Log.i(TAG, "No more spell check."); } stop(); return; } boolean scheduleOtherSpellCheck = false; if (sentenceEnd < textChangeEnd) { if (DBG) { Log.i(TAG, "schedule other spell check."); } // Several batches needed on that region. Cut after last previous word scheduleOtherSpellCheck = true; } int spellCheckEnd = sentenceEnd; do { int spellCheckStart = sentenceStart; boolean createSpellCheckSpan = true; // Cancel or merge overlapped spell check spans for (int i = 0; i < mLength; ++i) { final SpellCheckSpan spellCheckSpan = mSpellCheckSpans[i]; if (mIds[i] < 0 || spellCheckSpan.isSpellCheckInProgress()) { continue; } final int spanStart = editable.getSpanStart(spellCheckSpan); final int spanEnd = editable.getSpanEnd(spellCheckSpan); if (spanEnd < spellCheckStart || spellCheckEnd < spanStart) { // No need to merge continue; } if (spanStart <= spellCheckStart && spellCheckEnd <= spanEnd) { // There is a completely overlapped spell check span // skip this span createSpellCheckSpan = false; if (DBG) { Log.i(TAG, "The range is overrapped. Skip spell check."); } break; } // This spellCheckSpan is replaced by the one we are creating editable.removeSpan(spellCheckSpan); spellCheckStart = Math.min(spanStart, spellCheckStart); spellCheckEnd = Math.max(spanEnd, spellCheckEnd); } if (DBG) { Log.d(TAG, "addSpellCheckSpan: " + ", End = " + spellCheckEnd + ", Start = " + spellCheckStart + ", next = " + scheduleOtherSpellCheck + "\n" + editable.subSequence(spellCheckStart, spellCheckEnd)); } // Stop spell checking when there are no characters in the range. if (spellCheckEnd <= spellCheckStart) { Log.w(TAG, "Trying to spellcheck invalid region, from " + sentenceStart + " to " + spellCheckEnd); break; } if (createSpellCheckSpan) { addSpellCheckSpan(editable, spellCheckStart, spellCheckEnd); } } while (false); sentenceStart = spellCheckEnd; if (scheduleOtherSpellCheck && sentenceStart != BreakIterator.DONE && sentenceStart <= textChangeEnd) { // Update range span: start new spell check from last wordStart setRangeSpan(editable, sentenceStart, textChangeEnd); } else { removeRangeSpan(editable); } spellCheck(mForceCheckWhenEditingWord); } private void removeSpansAt(Editable editable, int offset, T[] spans) { final int length = spans.length; for (int i = 0; i < length; i++) { final T span = spans[i]; final int start = editable.getSpanStart(span); if (start > offset) continue; final int end = editable.getSpanEnd(span); if (end < offset) continue; editable.removeSpan(span); } } } private Range detectSentenceBoundary(CharSequence sequence, int textChangeStart, int textChangeEnd) { // Only process a substring of the full text due to performance concern. final int iteratorWindowStart = findSeparator(sequence, Math.max(0, textChangeStart - MAX_SENTENCE_LENGTH), Math.max(0, textChangeStart - 2 * MAX_SENTENCE_LENGTH)); final int iteratorWindowEnd = findSeparator(sequence, Math.min(textChangeStart + 2 * MAX_SENTENCE_LENGTH, textChangeEnd), Math.min(textChangeStart + 3 * MAX_SENTENCE_LENGTH, sequence.length())); if (DBG) { Log.d(TAG, "Set iterator window as [" + iteratorWindowStart + ", " + iteratorWindowEnd + ")."); } mSentenceIterator.setCharSequence(sequence, iteratorWindowStart, iteratorWindowEnd); // Detect the offset of sentence begin/end on the substring. int sentenceStart = mSentenceIterator.isBoundary(textChangeStart) ? textChangeStart : mSentenceIterator.preceding(textChangeStart); int sentenceEnd = mSentenceIterator.following(sentenceStart); if (sentenceEnd == BreakIterator.DONE) { sentenceEnd = iteratorWindowEnd; } if (DBG) { if (sentenceStart != sentenceEnd) { Log.d(TAG, "Sentence detected [" + sentenceStart + ", " + sentenceEnd + ")."); } } if (sentenceEnd - sentenceStart <= MAX_SENTENCE_LENGTH) { // Add more sentences until the MAX_SENTENCE_LENGTH limitation is reached. while (sentenceEnd < textChangeEnd) { int nextEnd = mSentenceIterator.following(sentenceEnd); if (nextEnd == BreakIterator.DONE || nextEnd - sentenceStart > MAX_SENTENCE_LENGTH) { break; } sentenceEnd = nextEnd; } } else { // If the sentence containing `textChangeStart` is longer than MAX_SENTENCE_LENGTH, // the sentence will be sliced into sub-sentences of about MAX_SENTENCE_LENGTH // characters each. This is done by processing the unchecked part of that sentence : // [textChangeStart, sentenceEnd) // // - If the `uncheckedLength` is bigger than MAX_SENTENCE_LENGTH, then check the // [textChangeStart, textChangeStart + MAX_SENTENCE_LENGTH), and leave the rest // part for the next check. // // - If the `uncheckedLength` is smaller than or equal to MAX_SENTENCE_LENGTH, // then check [sentenceEnd - MAX_SENTENCE_LENGTH, sentenceEnd). // // The offset should be rounded up to word boundary. int uncheckedLength = sentenceEnd - textChangeStart; if (uncheckedLength > MAX_SENTENCE_LENGTH) { sentenceEnd = findSeparator(sequence, textChangeStart + MAX_SENTENCE_LENGTH, sentenceEnd); sentenceStart = roundUpToWordStart(sequence, textChangeStart, sentenceStart); } else { sentenceStart = roundUpToWordStart(sequence, sentenceEnd - MAX_SENTENCE_LENGTH, sentenceStart); } } return new Range<>(sentenceStart, Math.max(sentenceStart, sentenceEnd)); } private int roundUpToWordStart(CharSequence sequence, int position, int frontBoundary) { if (isSeparator(sequence.charAt(position))) { return position; } int separator = findSeparator(sequence, position, frontBoundary); return separator != frontBoundary ? separator + 1 : frontBoundary; } /** * Search the range [start, end) of sequence and returns the position of the first separator. * If end is smaller than start, do a reverse search. * Returns `end` if no separator is found. */ private static int findSeparator(CharSequence sequence, int start, int end) { final int step = start < end ? 1 : -1; for (int i = start; i != end; i += step) { if (isSeparator(sequence.charAt(i))) { return i; } } return end; } public static boolean haveWordBoundariesChanged(final Editable editable, final int start, final int end, final int spanStart, final int spanEnd) { final boolean haveWordBoundariesChanged; if (spanEnd != start && spanStart != end) { haveWordBoundariesChanged = true; if (DBG) { Log.d(TAG, "(1) Text inside the span has been modified. Remove."); } } else if (spanEnd == start && start < editable.length()) { final int codePoint = Character.codePointAt(editable, start); haveWordBoundariesChanged = Character.isLetterOrDigit(codePoint); if (DBG) { Log.d(TAG, "(2) Characters have been appended to the spanned text. " + (haveWordBoundariesChanged ? "Remove.<" : "Keep. <") + (char)(codePoint) + ">, " + editable + ", " + editable.subSequence(spanStart, spanEnd) + ", " + start); } } else if (spanStart == end && end > 0) { final int codePoint = Character.codePointBefore(editable, end); haveWordBoundariesChanged = Character.isLetterOrDigit(codePoint); if (DBG) { Log.d(TAG, "(3) Characters have been prepended to the spanned text. " + (haveWordBoundariesChanged ? "Remove.<" : "Keep.<") + (char)(codePoint) + ">, " + editable + ", " + editable.subSequence(spanStart, spanEnd) + ", " + end); } } else { if (DBG) { Log.d(TAG, "(4) Characters adjacent to the spanned text were deleted. Keep."); } haveWordBoundariesChanged = false; } return haveWordBoundariesChanged; } }