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