Home | History | Annotate | Download | only in widget
      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.SpannableStringBuilder;
     23 import android.text.Spanned;
     24 import android.text.TextUtils;
     25 import android.text.method.WordIterator;
     26 import android.text.style.SpellCheckSpan;
     27 import android.text.style.SuggestionSpan;
     28 import android.util.Log;
     29 import android.util.LruCache;
     30 import android.view.textservice.SentenceSuggestionsInfo;
     31 import android.view.textservice.SpellCheckerSession;
     32 import android.view.textservice.SpellCheckerSession.SpellCheckerSessionListener;
     33 import android.view.textservice.SuggestionsInfo;
     34 import android.view.textservice.TextInfo;
     35 import android.view.textservice.TextServicesManager;
     36 
     37 import com.android.internal.util.ArrayUtils;
     38 
     39 import java.text.BreakIterator;
     40 import java.util.Locale;
     41 
     42 
     43 /**
     44  * Helper class for TextView. Bridge between the TextView and the Dictionnary 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 chunck 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 
    104     public SpellChecker(TextView textView) {
    105         mTextView = textView;
    106 
    107         // Arbitrary: these arrays will automatically double their sizes on demand
    108         final int size = ArrayUtils.idealObjectArraySize(1);
    109         mIds = new int[size];
    110         mSpellCheckSpans = new SpellCheckSpan[size];
    111 
    112         setLocale(mTextView.getSpellCheckerLocale());
    113 
    114         mCookie = hashCode();
    115     }
    116 
    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 
    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      */
    163     private boolean isSessionActive() {
    164         return mSpellCheckerSession != null;
    165     }
    166 
    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 
    182     private int nextSpellCheckSpanIndex() {
    183         for (int i = 0; i < mLength; i++) {
    184             if (mIds[i] < 0) return i;
    185         }
    186 
    187         if (mLength == mSpellCheckSpans.length) {
    188             final int newSize = mLength * 2;
    189             int[] newIds = new int[newSize];
    190             SpellCheckSpan[] newSpellCheckSpans = new SpellCheckSpan[newSize];
    191             System.arraycopy(mIds, 0, newIds, 0, mLength);
    192             System.arraycopy(mSpellCheckSpans, 0, newSpellCheckSpans, 0, mLength);
    193             mIds = newIds;
    194             mSpellCheckSpans = newSpellCheckSpans;
    195         }
    196 
    197         mSpellCheckSpans[mLength] = new SpellCheckSpan();
    198         mLength++;
    199         return mLength - 1;
    200     }
    201 
    202     private void addSpellCheckSpan(Editable editable, int start, int end) {
    203         final int index = nextSpellCheckSpanIndex();
    204         SpellCheckSpan spellCheckSpan = mSpellCheckSpans[index];
    205         editable.setSpan(spellCheckSpan, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
    206         spellCheckSpan.setSpellCheckInProgress(false);
    207         mIds[index] = mSpanSequenceCounter++;
    208     }
    209 
    210     public void onSpellCheckSpanRemoved(SpellCheckSpan spellCheckSpan) {
    211         // Recycle any removed SpellCheckSpan (from this code or during text edition)
    212         for (int i = 0; i < mLength; i++) {
    213             if (mSpellCheckSpans[i] == spellCheckSpan) {
    214                 mIds[i] = -1;
    215                 return;
    216             }
    217         }
    218     }
    219 
    220     public void onSelectionChanged() {
    221         spellCheck();
    222     }
    223 
    224     public void spellCheck(int start, int end) {
    225         if (DBG) {
    226             Log.d(TAG, "Start spell-checking: " + start + ", " + end);
    227         }
    228         final Locale locale = mTextView.getSpellCheckerLocale();
    229         final boolean isSessionActive = isSessionActive();
    230         if (locale == null || mCurrentLocale == null || (!(mCurrentLocale.equals(locale)))) {
    231             setLocale(locale);
    232             // Re-check the entire text
    233             start = 0;
    234             end = mTextView.getText().length();
    235         } else {
    236             final boolean spellCheckerActivated = mTextServicesManager.isSpellCheckerEnabled();
    237             if (isSessionActive != spellCheckerActivated) {
    238                 // Spell checker has been turned of or off since last spellCheck
    239                 resetSession();
    240             }
    241         }
    242 
    243         if (!isSessionActive) return;
    244 
    245         // Find first available SpellParser from pool
    246         final int length = mSpellParsers.length;
    247         for (int i = 0; i < length; i++) {
    248             final SpellParser spellParser = mSpellParsers[i];
    249             if (spellParser.isFinished()) {
    250                 spellParser.parse(start, end);
    251                 return;
    252             }
    253         }
    254 
    255         if (DBG) {
    256             Log.d(TAG, "new spell parser.");
    257         }
    258         // No available parser found in pool, create a new one
    259         SpellParser[] newSpellParsers = new SpellParser[length + 1];
    260         System.arraycopy(mSpellParsers, 0, newSpellParsers, 0, length);
    261         mSpellParsers = newSpellParsers;
    262 
    263         SpellParser spellParser = new SpellParser();
    264         mSpellParsers[length] = spellParser;
    265         spellParser.parse(start, end);
    266     }
    267 
    268     private void spellCheck() {
    269         if (mSpellCheckerSession == null) return;
    270 
    271         Editable editable = (Editable) mTextView.getText();
    272         final int selectionStart = Selection.getSelectionStart(editable);
    273         final int selectionEnd = Selection.getSelectionEnd(editable);
    274 
    275         TextInfo[] textInfos = new TextInfo[mLength];
    276         int textInfosCount = 0;
    277 
    278         for (int i = 0; i < mLength; i++) {
    279             final SpellCheckSpan spellCheckSpan = mSpellCheckSpans[i];
    280             if (mIds[i] < 0 || spellCheckSpan.isSpellCheckInProgress()) continue;
    281 
    282             final int start = editable.getSpanStart(spellCheckSpan);
    283             final int end = editable.getSpanEnd(spellCheckSpan);
    284 
    285             // Do not check this word if the user is currently editing it
    286             final boolean isEditing;
    287             if (mIsSentenceSpellCheckSupported) {
    288                 // Allow the overlap of the cursor and the first boundary of the spell check span
    289                 // no to skip the spell check of the following word because the
    290                 // following word will never be spell-checked even if the user finishes composing
    291                 isEditing = selectionEnd <= start || selectionStart > end;
    292             } else {
    293                 isEditing = selectionEnd < start || selectionStart > end;
    294             }
    295             if (start >= 0 && end > start && isEditing) {
    296                 final String word = (editable instanceof SpannableStringBuilder) ?
    297                         ((SpannableStringBuilder) editable).substring(start, end) :
    298                         editable.subSequence(start, end).toString();
    299                 spellCheckSpan.setSpellCheckInProgress(true);
    300                 textInfos[textInfosCount++] = new TextInfo(word, mCookie, mIds[i]);
    301                 if (DBG) {
    302                     Log.d(TAG, "create TextInfo: (" + i + "/" + mLength + ")" + word
    303                             + ", 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 
    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
    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
    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 
    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 
    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 
    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 
    524         public boolean isFinished() {
    525             return ((Editable) mTextView.getText()).getSpanStart(mRange) < 0;
    526         }
    527 
    528         public void stop() {
    529             removeRangeSpan((Editable) mTextView.getText());
    530         }
    531 
    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 
    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 
    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 <= end) {
    735                 // Update range span: start new spell check from last wordStart
    736                 setRangeSpan(editable, wordStart, end);
    737             } else {
    738                 if (DBG && scheduleOtherSpellCheck) {
    739                     Log.w(TAG, "Trying to schedule spellcheck for invalid region, from "
    740                             + wordStart + " to " + end);
    741                 }
    742                 removeRangeSpan(editable);
    743             }
    744 
    745             spellCheck();
    746         }
    747 
    748         private <T> void removeSpansAt(Editable editable, int offset, T[] spans) {
    749             final int length = spans.length;
    750             for (int i = 0; i < length; i++) {
    751                 final T span = spans[i];
    752                 final int start = editable.getSpanStart(span);
    753                 if (start > offset) continue;
    754                 final int end = editable.getSpanEnd(span);
    755                 if (end < offset) continue;
    756                 editable.removeSpan(span);
    757             }
    758         }
    759     }
    760 
    761     public static boolean haveWordBoundariesChanged(final Editable editable, final int start,
    762             final int end, final int spanStart, final int spanEnd) {
    763         final boolean haveWordBoundariesChanged;
    764         if (spanEnd != start && spanStart != end) {
    765             haveWordBoundariesChanged = true;
    766             if (DBG) {
    767                 Log.d(TAG, "(1) Text inside the span has been modified. Remove.");
    768             }
    769         } else if (spanEnd == start && start < editable.length()) {
    770             final int codePoint = Character.codePointAt(editable, start);
    771             haveWordBoundariesChanged = Character.isLetterOrDigit(codePoint);
    772             if (DBG) {
    773                 Log.d(TAG, "(2) Characters have been appended to the spanned text. "
    774                         + (haveWordBoundariesChanged ? "Remove.<" : "Keep. <") + (char)(codePoint)
    775                         + ">, " + editable + ", " + editable.subSequence(spanStart, spanEnd) + ", "
    776                         + start);
    777             }
    778         } else if (spanStart == end && end > 0) {
    779             final int codePoint = Character.codePointBefore(editable, end);
    780             haveWordBoundariesChanged = Character.isLetterOrDigit(codePoint);
    781             if (DBG) {
    782                 Log.d(TAG, "(3) Characters have been prepended to the spanned text. "
    783                         + (haveWordBoundariesChanged ? "Remove.<" : "Keep.<") + (char)(codePoint)
    784                         + ">, " + editable + ", " + editable.subSequence(spanStart, spanEnd) + ", "
    785                         + end);
    786             }
    787         } else {
    788             if (DBG) {
    789                 Log.d(TAG, "(4) Characters adjacent to the spanned text were deleted. Keep.");
    790             }
    791             haveWordBoundariesChanged = false;
    792         }
    793         return haveWordBoundariesChanged;
    794     }
    795 }
    796