| /* |
| * 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<Integer> 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 <T> 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<Integer> 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; |
| } |
| } |