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