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