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.TextUtils;
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.LruCache;
29 import android.view.textservice.SentenceSuggestionsInfo;
30 import android.view.textservice.SpellCheckerSession;
31 import android.view.textservice.SpellCheckerSession.SpellCheckerSessionListener;
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     private static final int MIN_SENTENCE_LENGTH = 50;
66 
67     private static final int USE_SPAN_RANGE = -1;
68 
69     private final TextView mTextView;
70 
71     SpellCheckerSession mSpellCheckerSession;
72     // We assume that the sentence level spell check will always provide better results than words.
73     // Although word SC has a sequential option.
74     private boolean mIsSentenceSpellCheckSupported;
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 WordIterator mWordIterator;
95 
96     @Nullable
97     private TextServicesManager mTextServicesManager;
98 
99     private Runnable mSpellRunnable;
100 
101     private static final int SUGGESTION_SPAN_CACHE_SIZE = 10;
102     private final LruCache<Long, SuggestionSpan> mSuggestionSpanCache =
103             new LruCache<Long, SuggestionSpan>(SUGGESTION_SPAN_CACHE_SIZE);
104 
SpellChecker(TextView textView)105     public SpellChecker(TextView textView) {
106         mTextView = textView;
107 
108         // Arbitrary: these arrays will automatically double their sizes on demand
109         final int size = 1;
110         mIds = ArrayUtils.newUnpaddedIntArray(size);
111         mSpellCheckSpans = new SpellCheckSpan[mIds.length];
112 
113         setLocale(mTextView.getSpellCheckerLocale());
114 
115         mCookie = hashCode();
116     }
117 
resetSession()118     void resetSession() {
119         closeSession();
120 
121         mTextServicesManager = mTextView.getTextServicesManagerForUser();
122         if (mCurrentLocale == null
123                 || mTextServicesManager == null
124                 || mTextView.length() == 0
125                 || !mTextServicesManager.isSpellCheckerEnabled()
126                 || mTextServicesManager.getCurrentSpellCheckerSubtype(true) == null) {
127             mSpellCheckerSession = null;
128         } else {
129             mSpellCheckerSession = mTextServicesManager.newSpellCheckerSession(
130                     null /* Bundle not currently used by the textServicesManager */,
131                     mCurrentLocale, this,
132                     false /* means any available languages from current spell checker */);
133             mIsSentenceSpellCheckSupported = true;
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         mSuggestionSpanCache.evictAll();
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' wordIterator locale
154             mWordIterator = new WordIterator(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 
spellCheck(int start, int end)218     public void spellCheck(int start, int end) {
219         if (DBG) {
220             Log.d(TAG, "Start spell-checking: " + start + ", " + end);
221         }
222         final Locale locale = mTextView.getSpellCheckerLocale();
223         final boolean isSessionActive = isSessionActive();
224         if (locale == null || mCurrentLocale == null || (!(mCurrentLocale.equals(locale)))) {
225             setLocale(locale);
226             // Re-check the entire text
227             start = 0;
228             end = mTextView.getText().length();
229         } else {
230             final boolean spellCheckerActivated =
231                     mTextServicesManager != null && mTextServicesManager.isSpellCheckerEnabled();
232             if (isSessionActive != spellCheckerActivated) {
233                 // Spell checker has been turned of or off since last spellCheck
234                 resetSession();
235             }
236         }
237 
238         if (!isSessionActive) return;
239 
240         // Find first available SpellParser from pool
241         final int length = mSpellParsers.length;
242         for (int i = 0; i < length; i++) {
243             final SpellParser spellParser = mSpellParsers[i];
244             if (spellParser.isFinished()) {
245                 spellParser.parse(start, end);
246                 return;
247             }
248         }
249 
250         if (DBG) {
251             Log.d(TAG, "new spell parser.");
252         }
253         // No available parser found in pool, create a new one
254         SpellParser[] newSpellParsers = new SpellParser[length + 1];
255         System.arraycopy(mSpellParsers, 0, newSpellParsers, 0, length);
256         mSpellParsers = newSpellParsers;
257 
258         SpellParser spellParser = new SpellParser();
259         mSpellParsers[length] = spellParser;
260         spellParser.parse(start, end);
261     }
262 
spellCheck()263     private void spellCheck() {
264         if (mSpellCheckerSession == null) return;
265 
266         Editable editable = (Editable) mTextView.getText();
267         final int selectionStart = Selection.getSelectionStart(editable);
268         final int selectionEnd = Selection.getSelectionEnd(editable);
269 
270         TextInfo[] textInfos = new TextInfo[mLength];
271         int textInfosCount = 0;
272 
273         for (int i = 0; i < mLength; i++) {
274             final SpellCheckSpan spellCheckSpan = mSpellCheckSpans[i];
275             if (mIds[i] < 0 || spellCheckSpan.isSpellCheckInProgress()) continue;
276 
277             final int start = editable.getSpanStart(spellCheckSpan);
278             final int end = editable.getSpanEnd(spellCheckSpan);
279 
280             // Do not check this word if the user is currently editing it
281             final boolean isEditing;
282 
283             // Defer spell check when typing a word ending with a punctuation like an apostrophe
284             // which could end up being a mid-word punctuation.
285             if (selectionStart == end + 1
286                     && WordIterator.isMidWordPunctuation(
287                             mCurrentLocale, Character.codePointBefore(editable, end + 1))) {
288                 isEditing = false;
289             } else if (mIsSentenceSpellCheckSupported) {
290                 // Allow the overlap of the cursor and the first boundary of the spell check span
291                 // no to skip the spell check of the following word because the
292                 // following word will never be spell-checked even if the user finishes composing
293                 isEditing = selectionEnd <= start || selectionStart > end;
294             } else {
295                 isEditing = selectionEnd < start || selectionStart > end;
296             }
297             if (start >= 0 && end > start && isEditing) {
298                 spellCheckSpan.setSpellCheckInProgress(true);
299                 final TextInfo textInfo = new TextInfo(editable, start, end, mCookie, mIds[i]);
300                 textInfos[textInfosCount++] = textInfo;
301                 if (DBG) {
302                     Log.d(TAG, "create TextInfo: (" + i + "/" + mLength + ") text = "
303                             + textInfo.getSequence() + ", cookie = " + mCookie + ", seq = "
304                             + mIds[i] + ", sel start = " + selectionStart + ", sel end = "
305                             + selectionEnd + ", start = " + start + ", end = " + end);
306                 }
307             }
308         }
309 
310         if (textInfosCount > 0) {
311             if (textInfosCount < textInfos.length) {
312                 TextInfo[] textInfosCopy = new TextInfo[textInfosCount];
313                 System.arraycopy(textInfos, 0, textInfosCopy, 0, textInfosCount);
314                 textInfos = textInfosCopy;
315             }
316 
317             if (mIsSentenceSpellCheckSupported) {
318                 mSpellCheckerSession.getSentenceSuggestions(
319                         textInfos, SuggestionSpan.SUGGESTIONS_MAX_SIZE);
320             } else {
321                 mSpellCheckerSession.getSuggestions(textInfos, SuggestionSpan.SUGGESTIONS_MAX_SIZE,
322                         false /* TODO Set sequentialWords to true for initial spell check */);
323             }
324         }
325     }
326 
onGetSuggestionsInternal( SuggestionsInfo suggestionsInfo, int offset, int length)327     private SpellCheckSpan onGetSuggestionsInternal(
328             SuggestionsInfo suggestionsInfo, int offset, int length) {
329         if (suggestionsInfo == null || suggestionsInfo.getCookie() != mCookie) {
330             return null;
331         }
332         final Editable editable = (Editable) mTextView.getText();
333         final int sequenceNumber = suggestionsInfo.getSequence();
334         for (int k = 0; k < mLength; ++k) {
335             if (sequenceNumber == mIds[k]) {
336                 final int attributes = suggestionsInfo.getSuggestionsAttributes();
337                 final boolean isInDictionary =
338                         ((attributes & SuggestionsInfo.RESULT_ATTR_IN_THE_DICTIONARY) > 0);
339                 final boolean looksLikeTypo =
340                         ((attributes & SuggestionsInfo.RESULT_ATTR_LOOKS_LIKE_TYPO) > 0);
341 
342                 final SpellCheckSpan spellCheckSpan = mSpellCheckSpans[k];
343                 //TODO: we need to change that rule for results from a sentence-level spell
344                 // checker that will probably be in dictionary.
345                 if (!isInDictionary && looksLikeTypo) {
346                     createMisspelledSuggestionSpan(
347                             editable, suggestionsInfo, spellCheckSpan, offset, length);
348                 } else {
349                     // Valid word -- isInDictionary || !looksLikeTypo
350                     if (mIsSentenceSpellCheckSupported) {
351                         // Allow the spell checker to remove existing misspelled span by
352                         // overwriting the span over the same place
353                         final int spellCheckSpanStart = editable.getSpanStart(spellCheckSpan);
354                         final int spellCheckSpanEnd = editable.getSpanEnd(spellCheckSpan);
355                         final int start;
356                         final int end;
357                         if (offset != USE_SPAN_RANGE && length != USE_SPAN_RANGE) {
358                             start = spellCheckSpanStart + offset;
359                             end = start + length;
360                         } else {
361                             start = spellCheckSpanStart;
362                             end = spellCheckSpanEnd;
363                         }
364                         if (spellCheckSpanStart >= 0 && spellCheckSpanEnd > spellCheckSpanStart
365                                 && end > start) {
366                             final Long key = Long.valueOf(TextUtils.packRangeInLong(start, end));
367                             final SuggestionSpan tempSuggestionSpan = mSuggestionSpanCache.get(key);
368                             if (tempSuggestionSpan != null) {
369                                 if (DBG) {
370                                     Log.i(TAG, "Remove existing misspelled span. "
371                                             + editable.subSequence(start, end));
372                                 }
373                                 editable.removeSpan(tempSuggestionSpan);
374                                 mSuggestionSpanCache.remove(key);
375                             }
376                         }
377                     }
378                 }
379                 return spellCheckSpan;
380             }
381         }
382         return null;
383     }
384 
385     @Override
onGetSuggestions(SuggestionsInfo[] results)386     public void onGetSuggestions(SuggestionsInfo[] results) {
387         final Editable editable = (Editable) mTextView.getText();
388         for (int i = 0; i < results.length; ++i) {
389             final SpellCheckSpan spellCheckSpan =
390                     onGetSuggestionsInternal(results[i], USE_SPAN_RANGE, USE_SPAN_RANGE);
391             if (spellCheckSpan != null) {
392                 // onSpellCheckSpanRemoved will recycle this span in the pool
393                 editable.removeSpan(spellCheckSpan);
394             }
395         }
396         scheduleNewSpellCheck();
397     }
398 
399     @Override
onGetSentenceSuggestions(SentenceSuggestionsInfo[] results)400     public void onGetSentenceSuggestions(SentenceSuggestionsInfo[] results) {
401         final Editable editable = (Editable) mTextView.getText();
402 
403         for (int i = 0; i < results.length; ++i) {
404             final SentenceSuggestionsInfo ssi = results[i];
405             if (ssi == null) {
406                 continue;
407             }
408             SpellCheckSpan spellCheckSpan = null;
409             for (int j = 0; j < ssi.getSuggestionsCount(); ++j) {
410                 final SuggestionsInfo suggestionsInfo = ssi.getSuggestionsInfoAt(j);
411                 if (suggestionsInfo == null) {
412                     continue;
413                 }
414                 final int offset = ssi.getOffsetAt(j);
415                 final int length = ssi.getLengthAt(j);
416                 final SpellCheckSpan scs = onGetSuggestionsInternal(
417                         suggestionsInfo, offset, length);
418                 if (spellCheckSpan == null && scs != null) {
419                     // the spellCheckSpan is shared by all the "SuggestionsInfo"s in the same
420                     // SentenceSuggestionsInfo. Removal is deferred after this loop.
421                     spellCheckSpan = scs;
422                 }
423             }
424             if (spellCheckSpan != null) {
425                 // onSpellCheckSpanRemoved will recycle this span in the pool
426                 editable.removeSpan(spellCheckSpan);
427             }
428         }
429         scheduleNewSpellCheck();
430     }
431 
scheduleNewSpellCheck()432     private void scheduleNewSpellCheck() {
433         if (DBG) {
434             Log.i(TAG, "schedule new spell check.");
435         }
436         if (mSpellRunnable == null) {
437             mSpellRunnable = new Runnable() {
438                 @Override
439                 public void run() {
440                     final int length = mSpellParsers.length;
441                     for (int i = 0; i < length; i++) {
442                         final SpellParser spellParser = mSpellParsers[i];
443                         if (!spellParser.isFinished()) {
444                             spellParser.parse();
445                             break; // run one spell parser at a time to bound running time
446                         }
447                     }
448                 }
449             };
450         } else {
451             mTextView.removeCallbacks(mSpellRunnable);
452         }
453 
454         mTextView.postDelayed(mSpellRunnable, SPELL_PAUSE_DURATION);
455     }
456 
createMisspelledSuggestionSpan(Editable editable, SuggestionsInfo suggestionsInfo, SpellCheckSpan spellCheckSpan, int offset, int length)457     private void createMisspelledSuggestionSpan(Editable editable, SuggestionsInfo suggestionsInfo,
458             SpellCheckSpan spellCheckSpan, int offset, int length) {
459         final int spellCheckSpanStart = editable.getSpanStart(spellCheckSpan);
460         final int spellCheckSpanEnd = editable.getSpanEnd(spellCheckSpan);
461         if (spellCheckSpanStart < 0 || spellCheckSpanEnd <= spellCheckSpanStart)
462             return; // span was removed in the meantime
463 
464         final int start;
465         final int end;
466         if (offset != USE_SPAN_RANGE && length != USE_SPAN_RANGE) {
467             start = spellCheckSpanStart + offset;
468             end = start + length;
469         } else {
470             start = spellCheckSpanStart;
471             end = spellCheckSpanEnd;
472         }
473 
474         final int suggestionsCount = suggestionsInfo.getSuggestionsCount();
475         String[] suggestions;
476         if (suggestionsCount > 0) {
477             suggestions = new String[suggestionsCount];
478             for (int i = 0; i < suggestionsCount; i++) {
479                 suggestions[i] = suggestionsInfo.getSuggestionAt(i);
480             }
481         } else {
482             suggestions = ArrayUtils.emptyArray(String.class);
483         }
484 
485         SuggestionSpan suggestionSpan = new SuggestionSpan(mTextView.getContext(), suggestions,
486                 SuggestionSpan.FLAG_EASY_CORRECT | SuggestionSpan.FLAG_MISSPELLED);
487         // TODO: Remove mIsSentenceSpellCheckSupported by extracting an interface
488         // to share the logic of word level spell checker and sentence level spell checker
489         if (mIsSentenceSpellCheckSupported) {
490             final Long key = Long.valueOf(TextUtils.packRangeInLong(start, end));
491             final SuggestionSpan tempSuggestionSpan = mSuggestionSpanCache.get(key);
492             if (tempSuggestionSpan != null) {
493                 if (DBG) {
494                     Log.i(TAG, "Cached span on the same position is cleard. "
495                             + editable.subSequence(start, end));
496                 }
497                 editable.removeSpan(tempSuggestionSpan);
498             }
499             mSuggestionSpanCache.put(key, suggestionSpan);
500         }
501         editable.setSpan(suggestionSpan, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
502 
503         mTextView.invalidateRegion(start, end, false /* No cursor involved */);
504     }
505 
506     private class SpellParser {
507         private Object mRange = new Object();
508 
parse(int start, int end)509         public void parse(int start, int end) {
510             final int max = mTextView.length();
511             final int parseEnd;
512             if (end > max) {
513                 Log.w(TAG, "Parse invalid region, from " + start + " to " + end);
514                 parseEnd = max;
515             } else {
516                 parseEnd = end;
517             }
518             if (parseEnd > start) {
519                 setRangeSpan((Editable) mTextView.getText(), start, parseEnd);
520                 parse();
521             }
522         }
523 
isFinished()524         public boolean isFinished() {
525             return ((Editable) mTextView.getText()).getSpanStart(mRange) < 0;
526         }
527 
stop()528         public void stop() {
529             removeRangeSpan((Editable) mTextView.getText());
530         }
531 
setRangeSpan(Editable editable, int start, int end)532         private void setRangeSpan(Editable editable, int start, int end) {
533             if (DBG) {
534                 Log.d(TAG, "set next range span: " + start + ", " + end);
535             }
536             editable.setSpan(mRange, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
537         }
538 
removeRangeSpan(Editable editable)539         private void removeRangeSpan(Editable editable) {
540             if (DBG) {
541                 Log.d(TAG, "Remove range span." + editable.getSpanStart(editable)
542                         + editable.getSpanEnd(editable));
543             }
544             editable.removeSpan(mRange);
545         }
546 
parse()547         public void parse() {
548             Editable editable = (Editable) mTextView.getText();
549             // Iterate over the newly added text and schedule new SpellCheckSpans
550             final int start;
551             if (mIsSentenceSpellCheckSupported) {
552                 // TODO: Find the start position of the sentence.
553                 // Set span with the context
554                 start =  Math.max(
555                         0, editable.getSpanStart(mRange) - MIN_SENTENCE_LENGTH);
556             } else {
557                 start = editable.getSpanStart(mRange);
558             }
559 
560             final int end = editable.getSpanEnd(mRange);
561 
562             int wordIteratorWindowEnd = Math.min(end, start + WORD_ITERATOR_INTERVAL);
563             mWordIterator.setCharSequence(editable, start, wordIteratorWindowEnd);
564 
565             // Move back to the beginning of the current word, if any
566             int wordStart = mWordIterator.preceding(start);
567             int wordEnd;
568             if (wordStart == BreakIterator.DONE) {
569                 wordEnd = mWordIterator.following(start);
570                 if (wordEnd != BreakIterator.DONE) {
571                     wordStart = mWordIterator.getBeginning(wordEnd);
572                 }
573             } else {
574                 wordEnd = mWordIterator.getEnd(wordStart);
575             }
576             if (wordEnd == BreakIterator.DONE) {
577                 if (DBG) {
578                     Log.i(TAG, "No more spell check.");
579                 }
580                 removeRangeSpan(editable);
581                 return;
582             }
583 
584             // We need to expand by one character because we want to include the spans that
585             // end/start at position start/end respectively.
586             SpellCheckSpan[] spellCheckSpans = editable.getSpans(start - 1, end + 1,
587                     SpellCheckSpan.class);
588             SuggestionSpan[] suggestionSpans = editable.getSpans(start - 1, end + 1,
589                     SuggestionSpan.class);
590 
591             int wordCount = 0;
592             boolean scheduleOtherSpellCheck = false;
593 
594             if (mIsSentenceSpellCheckSupported) {
595                 if (wordIteratorWindowEnd < end) {
596                     if (DBG) {
597                         Log.i(TAG, "schedule other spell check.");
598                     }
599                     // Several batches needed on that region. Cut after last previous word
600                     scheduleOtherSpellCheck = true;
601                 }
602                 int spellCheckEnd = mWordIterator.preceding(wordIteratorWindowEnd);
603                 boolean correct = spellCheckEnd != BreakIterator.DONE;
604                 if (correct) {
605                     spellCheckEnd = mWordIterator.getEnd(spellCheckEnd);
606                     correct = spellCheckEnd != BreakIterator.DONE;
607                 }
608                 if (!correct) {
609                     if (DBG) {
610                         Log.i(TAG, "Incorrect range span.");
611                     }
612                     removeRangeSpan(editable);
613                     return;
614                 }
615                 do {
616                     // TODO: Find the start position of the sentence.
617                     int spellCheckStart = wordStart;
618                     boolean createSpellCheckSpan = true;
619                     // Cancel or merge overlapped spell check spans
620                     for (int i = 0; i < mLength; ++i) {
621                         final SpellCheckSpan spellCheckSpan = mSpellCheckSpans[i];
622                         if (mIds[i] < 0 || spellCheckSpan.isSpellCheckInProgress()) {
623                             continue;
624                         }
625                         final int spanStart = editable.getSpanStart(spellCheckSpan);
626                         final int spanEnd = editable.getSpanEnd(spellCheckSpan);
627                         if (spanEnd < spellCheckStart || spellCheckEnd < spanStart) {
628                             // No need to merge
629                             continue;
630                         }
631                         if (spanStart <= spellCheckStart && spellCheckEnd <= spanEnd) {
632                             // There is a completely overlapped spell check span
633                             // skip this span
634                             createSpellCheckSpan = false;
635                             if (DBG) {
636                                 Log.i(TAG, "The range is overrapped. Skip spell check.");
637                             }
638                             break;
639                         }
640                         // This spellCheckSpan is replaced by the one we are creating
641                         editable.removeSpan(spellCheckSpan);
642                         spellCheckStart = Math.min(spanStart, spellCheckStart);
643                         spellCheckEnd = Math.max(spanEnd, spellCheckEnd);
644                     }
645 
646                     if (DBG) {
647                         Log.d(TAG, "addSpellCheckSpan: "
648                                 + ", End = " + spellCheckEnd + ", Start = " + spellCheckStart
649                                 + ", next = " + scheduleOtherSpellCheck + "\n"
650                                 + editable.subSequence(spellCheckStart, spellCheckEnd));
651                     }
652 
653                     // Stop spell checking when there are no characters in the range.
654                     if (spellCheckEnd < start) {
655                         break;
656                     }
657                     if (spellCheckEnd <= spellCheckStart) {
658                         Log.w(TAG, "Trying to spellcheck invalid region, from "
659                                 + start + " to " + end);
660                         break;
661                     }
662                     if (createSpellCheckSpan) {
663                         addSpellCheckSpan(editable, spellCheckStart, spellCheckEnd);
664                     }
665                 } while (false);
666                 wordStart = spellCheckEnd;
667             } else {
668                 while (wordStart <= end) {
669                     if (wordEnd >= start && wordEnd > wordStart) {
670                         if (wordCount >= MAX_NUMBER_OF_WORDS) {
671                             scheduleOtherSpellCheck = true;
672                             break;
673                         }
674                         // A new word has been created across the interval boundaries with this
675                         // edit. The previous spans (that ended on start / started on end) are
676                         // not valid anymore and must be removed.
677                         if (wordStart < start && wordEnd > start) {
678                             removeSpansAt(editable, start, spellCheckSpans);
679                             removeSpansAt(editable, start, suggestionSpans);
680                         }
681 
682                         if (wordStart < end && wordEnd > end) {
683                             removeSpansAt(editable, end, spellCheckSpans);
684                             removeSpansAt(editable, end, suggestionSpans);
685                         }
686 
687                         // Do not create new boundary spans if they already exist
688                         boolean createSpellCheckSpan = true;
689                         if (wordEnd == start) {
690                             for (int i = 0; i < spellCheckSpans.length; i++) {
691                                 final int spanEnd = editable.getSpanEnd(spellCheckSpans[i]);
692                                 if (spanEnd == start) {
693                                     createSpellCheckSpan = false;
694                                     break;
695                                 }
696                             }
697                         }
698 
699                         if (wordStart == end) {
700                             for (int i = 0; i < spellCheckSpans.length; i++) {
701                                 final int spanStart = editable.getSpanStart(spellCheckSpans[i]);
702                                 if (spanStart == end) {
703                                     createSpellCheckSpan = false;
704                                     break;
705                                 }
706                             }
707                         }
708 
709                         if (createSpellCheckSpan) {
710                             addSpellCheckSpan(editable, wordStart, wordEnd);
711                         }
712                         wordCount++;
713                     }
714 
715                     // iterate word by word
716                     int originalWordEnd = wordEnd;
717                     wordEnd = mWordIterator.following(wordEnd);
718                     if ((wordIteratorWindowEnd < end) &&
719                             (wordEnd == BreakIterator.DONE || wordEnd >= wordIteratorWindowEnd)) {
720                         wordIteratorWindowEnd =
721                                 Math.min(end, originalWordEnd + WORD_ITERATOR_INTERVAL);
722                         mWordIterator.setCharSequence(
723                                 editable, originalWordEnd, wordIteratorWindowEnd);
724                         wordEnd = mWordIterator.following(originalWordEnd);
725                     }
726                     if (wordEnd == BreakIterator.DONE) break;
727                     wordStart = mWordIterator.getBeginning(wordEnd);
728                     if (wordStart == BreakIterator.DONE) {
729                         break;
730                     }
731                 }
732             }
733 
734             if (scheduleOtherSpellCheck && wordStart != BreakIterator.DONE && wordStart <= end) {
735                 // Update range span: start new spell check from last wordStart
736                 setRangeSpan(editable, wordStart, end);
737             } else {
738                 removeRangeSpan(editable);
739             }
740 
741             spellCheck();
742         }
743 
removeSpansAt(Editable editable, int offset, T[] spans)744         private <T> void removeSpansAt(Editable editable, int offset, T[] spans) {
745             final int length = spans.length;
746             for (int i = 0; i < length; i++) {
747                 final T span = spans[i];
748                 final int start = editable.getSpanStart(span);
749                 if (start > offset) continue;
750                 final int end = editable.getSpanEnd(span);
751                 if (end < offset) continue;
752                 editable.removeSpan(span);
753             }
754         }
755     }
756 
haveWordBoundariesChanged(final Editable editable, final int start, final int end, final int spanStart, final int spanEnd)757     public static boolean haveWordBoundariesChanged(final Editable editable, final int start,
758             final int end, final int spanStart, final int spanEnd) {
759         final boolean haveWordBoundariesChanged;
760         if (spanEnd != start && spanStart != end) {
761             haveWordBoundariesChanged = true;
762             if (DBG) {
763                 Log.d(TAG, "(1) Text inside the span has been modified. Remove.");
764             }
765         } else if (spanEnd == start && start < editable.length()) {
766             final int codePoint = Character.codePointAt(editable, start);
767             haveWordBoundariesChanged = Character.isLetterOrDigit(codePoint);
768             if (DBG) {
769                 Log.d(TAG, "(2) Characters have been appended to the spanned text. "
770                         + (haveWordBoundariesChanged ? "Remove.<" : "Keep. <") + (char)(codePoint)
771                         + ">, " + editable + ", " + editable.subSequence(spanStart, spanEnd) + ", "
772                         + start);
773             }
774         } else if (spanStart == end && end > 0) {
775             final int codePoint = Character.codePointBefore(editable, end);
776             haveWordBoundariesChanged = Character.isLetterOrDigit(codePoint);
777             if (DBG) {
778                 Log.d(TAG, "(3) Characters have been prepended to the spanned text. "
779                         + (haveWordBoundariesChanged ? "Remove.<" : "Keep.<") + (char)(codePoint)
780                         + ">, " + editable + ", " + editable.subSequence(spanStart, spanEnd) + ", "
781                         + end);
782             }
783         } else {
784             if (DBG) {
785                 Log.d(TAG, "(4) Characters adjacent to the spanned text were deleted. Keep.");
786             }
787             haveWordBoundariesChanged = false;
788         }
789         return haveWordBoundariesChanged;
790     }
791 }
792