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