SpellChecker.java revision 653d3a27878d5358b4a91518a756f6b9b3407b07
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
17package android.widget;
18
19import android.content.Context;
20import android.text.Editable;
21import android.text.Selection;
22import android.text.SpannableStringBuilder;
23import android.text.Spanned;
24import android.text.method.WordIterator;
25import android.text.style.SpellCheckSpan;
26import android.text.style.SuggestionSpan;
27import android.view.textservice.SpellCheckerSession;
28import android.view.textservice.SpellCheckerSession.SpellCheckerSessionListener;
29import android.view.textservice.SuggestionsInfo;
30import android.view.textservice.TextInfo;
31import android.view.textservice.TextServicesManager;
32
33import com.android.internal.util.ArrayUtils;
34
35import java.text.BreakIterator;
36import java.util.Locale;
37
38
39/**
40 * Helper class for TextView. Bridge between the TextView and the Dictionnary service.
41 *
42 * @hide
43 */
44public class SpellChecker implements SpellCheckerSessionListener {
45
46    private final static int MAX_SPELL_BATCH_SIZE = 50;
47
48    private final TextView mTextView;
49
50    SpellCheckerSession mSpellCheckerSession;
51    final int mCookie;
52
53    // Paired arrays for the (id, spellCheckSpan) pair. A negative id means the associated
54    // SpellCheckSpan has been recycled and can be-reused.
55    // Contains null SpellCheckSpans after index mLength.
56    private int[] mIds;
57    private SpellCheckSpan[] mSpellCheckSpans;
58    // The mLength first elements of the above arrays have been initialized
59    private int mLength;
60
61    // Parsers on chunck of text, cutting text into words that will be checked
62    private SpellParser[] mSpellParsers = new SpellParser[0];
63
64    private int mSpanSequenceCounter = 0;
65
66    private Locale mCurrentLocale;
67
68    // Shared by all SpellParsers. Cannot be shared with TextView since it may be used
69    // concurrently due to the asynchronous nature of onGetSuggestions.
70    private WordIterator mWordIterator;
71
72    private TextServicesManager mTextServicesManager;
73
74    public SpellChecker(TextView textView) {
75        mTextView = textView;
76
77        // Arbitrary: these arrays will automatically double their sizes on demand
78        final int size = ArrayUtils.idealObjectArraySize(1);
79        mIds = new int[size];
80        mSpellCheckSpans = new SpellCheckSpan[size];
81
82        setLocale(mTextView.getTextServicesLocale());
83
84        mCookie = hashCode();
85    }
86
87    private void resetSession() {
88        closeSession();
89
90        mTextServicesManager = (TextServicesManager) mTextView.getContext().
91                getSystemService(Context.TEXT_SERVICES_MANAGER_SERVICE);
92        if (!mTextServicesManager.isSpellCheckerEnabled()) {
93            mSpellCheckerSession = null;
94        } else {
95            mSpellCheckerSession = mTextServicesManager.newSpellCheckerSession(
96                    null /* Bundle not currently used by the textServicesManager */,
97                    mCurrentLocale, this,
98                    false /* means any available languages from current spell checker */);
99        }
100
101        // Restore SpellCheckSpans in pool
102        for (int i = 0; i < mLength; i++) {
103            mSpellCheckSpans[i].setSpellCheckInProgress(false);
104            mIds[i] = -1;
105        }
106        mLength = 0;
107
108        // Remove existing misspelled SuggestionSpans
109        mTextView.removeMisspelledSpans((Editable) mTextView.getText());
110
111        // This class is the listener for locale change: warn other locale-aware objects
112        mTextView.onLocaleChanged();
113    }
114
115    private void setLocale(Locale locale) {
116        mCurrentLocale = locale;
117
118        resetSession();
119
120        // Change SpellParsers' wordIterator locale
121        mWordIterator = new WordIterator(locale);
122
123        // This class is the listener for locale change: warn other locale-aware objects
124        mTextView.onLocaleChanged();
125    }
126
127    /**
128     * @return true if a spell checker session has successfully been created. Returns false if not,
129     * for instance when spell checking has been disabled in settings.
130     */
131    private boolean isSessionActive() {
132        return mSpellCheckerSession != null;
133    }
134
135    public void closeSession() {
136        if (mSpellCheckerSession != null) {
137            mSpellCheckerSession.close();
138        }
139
140        final int length = mSpellParsers.length;
141        for (int i = 0; i < length; i++) {
142            mSpellParsers[i].finish();
143        }
144    }
145
146    private int nextSpellCheckSpanIndex() {
147        for (int i = 0; i < mLength; i++) {
148            if (mIds[i] < 0) return i;
149        }
150
151        if (mLength == mSpellCheckSpans.length) {
152            final int newSize = mLength * 2;
153            int[] newIds = new int[newSize];
154            SpellCheckSpan[] newSpellCheckSpans = new SpellCheckSpan[newSize];
155            System.arraycopy(mIds, 0, newIds, 0, mLength);
156            System.arraycopy(mSpellCheckSpans, 0, newSpellCheckSpans, 0, mLength);
157            mIds = newIds;
158            mSpellCheckSpans = newSpellCheckSpans;
159        }
160
161        mSpellCheckSpans[mLength] = new SpellCheckSpan();
162        mLength++;
163        return mLength - 1;
164    }
165
166    private void addSpellCheckSpan(Editable editable, int start, int end) {
167        final int index = nextSpellCheckSpanIndex();
168        editable.setSpan(mSpellCheckSpans[index], start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
169        mIds[index] = mSpanSequenceCounter++;
170    }
171
172    public void removeSpellCheckSpan(SpellCheckSpan spellCheckSpan) {
173        for (int i = 0; i < mLength; i++) {
174            if (mSpellCheckSpans[i] == spellCheckSpan) {
175                mSpellCheckSpans[i].setSpellCheckInProgress(false);
176                mIds[i] = -1;
177                return;
178            }
179        }
180    }
181
182    public void onSelectionChanged() {
183        spellCheck();
184    }
185
186    public void spellCheck(int start, int end) {
187        final Locale locale = mTextView.getTextServicesLocale();
188        if (mCurrentLocale == null || (!(mCurrentLocale.equals(locale)))) {
189            setLocale(locale);
190            // Re-check the entire text
191            start = 0;
192            end = mTextView.getText().length();
193        } else {
194            final boolean spellCheckerActivated = mTextServicesManager.isSpellCheckerEnabled();
195            if (isSessionActive() != spellCheckerActivated) {
196                // Spell checker has been turned of or off since last spellCheck
197                resetSession();
198            }
199        }
200
201        if (!isSessionActive()) return;
202
203        final int length = mSpellParsers.length;
204        for (int i = 0; i < length; i++) {
205            final SpellParser spellParser = mSpellParsers[i];
206            if (spellParser.isFinished()) {
207                spellParser.init(start, end);
208                spellParser.parse();
209                return;
210            }
211        }
212
213        // No available parser found in pool, create a new one
214        SpellParser[] newSpellParsers = new SpellParser[length + 1];
215        System.arraycopy(mSpellParsers, 0, newSpellParsers, 0, length);
216        mSpellParsers = newSpellParsers;
217
218        SpellParser spellParser = new SpellParser();
219        mSpellParsers[length] = spellParser;
220        spellParser.init(start, end);
221        spellParser.parse();
222    }
223
224    private void spellCheck() {
225        if (mSpellCheckerSession == null) return;
226
227        Editable editable = (Editable) mTextView.getText();
228        final int selectionStart = Selection.getSelectionStart(editable);
229        final int selectionEnd = Selection.getSelectionEnd(editable);
230
231        TextInfo[] textInfos = new TextInfo[mLength];
232        int textInfosCount = 0;
233
234        for (int i = 0; i < mLength; i++) {
235            final SpellCheckSpan spellCheckSpan = mSpellCheckSpans[i];
236            if (spellCheckSpan.isSpellCheckInProgress()) continue;
237
238            final int start = editable.getSpanStart(spellCheckSpan);
239            final int end = editable.getSpanEnd(spellCheckSpan);
240
241            // Do not check this word if the user is currently editing it
242            if (start >= 0 && end > start && (selectionEnd < start || selectionStart > end)) {
243                final String word = (editable instanceof SpannableStringBuilder) ?
244                        ((SpannableStringBuilder) editable).substring(start, end) :
245                        editable.subSequence(start, end).toString();
246                spellCheckSpan.setSpellCheckInProgress(true);
247                textInfos[textInfosCount++] = new TextInfo(word, mCookie, mIds[i]);
248            }
249        }
250
251        if (textInfosCount > 0) {
252            if (textInfosCount < textInfos.length) {
253                TextInfo[] textInfosCopy = new TextInfo[textInfosCount];
254                System.arraycopy(textInfos, 0, textInfosCopy, 0, textInfosCount);
255                textInfos = textInfosCopy;
256            }
257            mSpellCheckerSession.getSuggestions(textInfos, SuggestionSpan.SUGGESTIONS_MAX_SIZE,
258                    false /* TODO Set sequentialWords to true for initial spell check */);
259        }
260    }
261
262    @Override
263    public void onGetSuggestions(SuggestionsInfo[] results) {
264        Editable editable = (Editable) mTextView.getText();
265
266        for (int i = 0; i < results.length; i++) {
267            SuggestionsInfo suggestionsInfo = results[i];
268            if (suggestionsInfo.getCookie() != mCookie) continue;
269            final int sequenceNumber = suggestionsInfo.getSequence();
270
271            for (int j = 0; j < mLength; j++) {
272                if (sequenceNumber == mIds[j]) {
273                    final int attributes = suggestionsInfo.getSuggestionsAttributes();
274                    boolean isInDictionary =
275                            ((attributes & SuggestionsInfo.RESULT_ATTR_IN_THE_DICTIONARY) > 0);
276                    boolean looksLikeTypo =
277                            ((attributes & SuggestionsInfo.RESULT_ATTR_LOOKS_LIKE_TYPO) > 0);
278
279                    SpellCheckSpan spellCheckSpan = mSpellCheckSpans[j];
280
281                    if (!isInDictionary && looksLikeTypo) {
282                        createMisspelledSuggestionSpan(editable, suggestionsInfo, spellCheckSpan);
283                    }
284
285                    editable.removeSpan(spellCheckSpan);
286                    break;
287                }
288            }
289        }
290
291        final int length = mSpellParsers.length;
292        for (int i = 0; i < length; i++) {
293            final SpellParser spellParser = mSpellParsers[i];
294            if (!spellParser.isFinished()) {
295                spellParser.parse();
296            }
297        }
298    }
299
300    private void createMisspelledSuggestionSpan(Editable editable, SuggestionsInfo suggestionsInfo,
301            SpellCheckSpan spellCheckSpan) {
302        final int start = editable.getSpanStart(spellCheckSpan);
303        final int end = editable.getSpanEnd(spellCheckSpan);
304        if (start < 0 || end <= start) return; // span was removed in the meantime
305
306        // Other suggestion spans may exist on that region, with identical suggestions, filter
307        // them out to avoid duplicates.
308        SuggestionSpan[] suggestionSpans = editable.getSpans(start, end, SuggestionSpan.class);
309        final int length = suggestionSpans.length;
310        for (int i = 0; i < length; i++) {
311            final int spanStart = editable.getSpanStart(suggestionSpans[i]);
312            final int spanEnd = editable.getSpanEnd(suggestionSpans[i]);
313            if (spanStart != start || spanEnd != end) {
314                // Nulled (to avoid new array allocation) if not on that exact same region
315                suggestionSpans[i] = null;
316            }
317        }
318
319        final int suggestionsCount = suggestionsInfo.getSuggestionsCount();
320        String[] suggestions;
321        if (suggestionsCount <= 0) {
322            // A negative suggestion count is possible
323            suggestions = ArrayUtils.emptyArray(String.class);
324        } else {
325            int numberOfSuggestions = 0;
326            suggestions = new String[suggestionsCount];
327
328            for (int i = 0; i < suggestionsCount; i++) {
329                final String spellSuggestion = suggestionsInfo.getSuggestionAt(i);
330                if (spellSuggestion == null) break;
331                boolean suggestionFound = false;
332
333                for (int j = 0; j < length && !suggestionFound; j++) {
334                    if (suggestionSpans[j] == null) break;
335
336                    String[] suggests = suggestionSpans[j].getSuggestions();
337                    for (int k = 0; k < suggests.length; k++) {
338                        if (spellSuggestion.equals(suggests[k])) {
339                            // The suggestion is already provided by an other SuggestionSpan
340                            suggestionFound = true;
341                            break;
342                        }
343                    }
344                }
345
346                if (!suggestionFound) {
347                    suggestions[numberOfSuggestions++] = spellSuggestion;
348                }
349            }
350
351            if (numberOfSuggestions != suggestionsCount) {
352                String[] newSuggestions = new String[numberOfSuggestions];
353                System.arraycopy(suggestions, 0, newSuggestions, 0, numberOfSuggestions);
354                suggestions = newSuggestions;
355            }
356        }
357
358        SuggestionSpan suggestionSpan = new SuggestionSpan(mTextView.getContext(), suggestions,
359                SuggestionSpan.FLAG_EASY_CORRECT | SuggestionSpan.FLAG_MISSPELLED);
360        editable.setSpan(suggestionSpan, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
361
362        mTextView.invalidateRegion(start, end);
363    }
364
365    private class SpellParser {
366        private Object mRange = new Object();
367
368        public void init(int start, int end) {
369            ((Editable) mTextView.getText()).setSpan(mRange, start, end,
370                    Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
371        }
372
373        public void finish() {
374            ((Editable) mTextView.getText()).removeSpan(mRange);
375        }
376
377        public boolean isFinished() {
378            return ((Editable) mTextView.getText()).getSpanStart(mRange) < 0;
379        }
380
381        public void parse() {
382            Editable editable = (Editable) mTextView.getText();
383            // Iterate over the newly added text and schedule new SpellCheckSpans
384            final int start = editable.getSpanStart(mRange);
385            final int end = editable.getSpanEnd(mRange);
386            mWordIterator.setCharSequence(editable, start, end);
387
388            // Move back to the beginning of the current word, if any
389            int wordStart = mWordIterator.preceding(start);
390            int wordEnd;
391            if (wordStart == BreakIterator.DONE) {
392                wordEnd = mWordIterator.following(start);
393                if (wordEnd != BreakIterator.DONE) {
394                    wordStart = mWordIterator.getBeginning(wordEnd);
395                }
396            } else {
397                wordEnd = mWordIterator.getEnd(wordStart);
398            }
399            if (wordEnd == BreakIterator.DONE) {
400                editable.removeSpan(mRange);
401                return;
402            }
403
404            // We need to expand by one character because we want to include the spans that
405            // end/start at position start/end respectively.
406            SpellCheckSpan[] spellCheckSpans = editable.getSpans(start - 1, end + 1,
407                    SpellCheckSpan.class);
408            SuggestionSpan[] suggestionSpans = editable.getSpans(start - 1, end + 1,
409                    SuggestionSpan.class);
410
411            int nbWordsChecked = 0;
412            boolean scheduleOtherSpellCheck = false;
413
414            while (wordStart <= end) {
415                if (wordEnd >= start && wordEnd > wordStart) {
416                    // A new word has been created across the interval boundaries with this edit.
417                    // Previous spans (ended on start / started on end) removed, not valid anymore
418                    if (wordStart < start && wordEnd > start) {
419                        removeSpansAt(editable, start, spellCheckSpans);
420                        removeSpansAt(editable, start, suggestionSpans);
421                    }
422
423                    if (wordStart < end && wordEnd > end) {
424                        removeSpansAt(editable, end, spellCheckSpans);
425                        removeSpansAt(editable, end, suggestionSpans);
426                    }
427
428                    // Do not create new boundary spans if they already exist
429                    boolean createSpellCheckSpan = true;
430                    if (wordEnd == start) {
431                        for (int i = 0; i < spellCheckSpans.length; i++) {
432                            final int spanEnd = editable.getSpanEnd(spellCheckSpans[i]);
433                            if (spanEnd == start) {
434                                createSpellCheckSpan = false;
435                                break;
436                            }
437                        }
438                    }
439
440                    if (wordStart == end) {
441                        for (int i = 0; i < spellCheckSpans.length; i++) {
442                            final int spanStart = editable.getSpanStart(spellCheckSpans[i]);
443                            if (spanStart == end) {
444                                createSpellCheckSpan = false;
445                                break;
446                            }
447                        }
448                    }
449
450                    if (createSpellCheckSpan) {
451                        if (nbWordsChecked == MAX_SPELL_BATCH_SIZE) {
452                            scheduleOtherSpellCheck = true;
453                            break;
454                        }
455                        addSpellCheckSpan(editable, wordStart, wordEnd);
456                        nbWordsChecked++;
457                    }
458                }
459
460                // iterate word by word
461                wordEnd = mWordIterator.following(wordEnd);
462                if (wordEnd == BreakIterator.DONE) break;
463                wordStart = mWordIterator.getBeginning(wordEnd);
464                if (wordStart == BreakIterator.DONE) {
465                    break;
466                }
467            }
468
469            if (scheduleOtherSpellCheck) {
470                editable.setSpan(mRange, wordStart, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
471            } else {
472                editable.removeSpan(mRange);
473            }
474
475            spellCheck();
476        }
477
478        private <T> void removeSpansAt(Editable editable, int offset, T[] spans) {
479            final int length = spans.length;
480            for (int i = 0; i < length; i++) {
481                final T span = spans[i];
482                final int start = editable.getSpanStart(span);
483                if (start > offset) continue;
484                final int end = editable.getSpanEnd(span);
485                if (end < offset) continue;
486                editable.removeSpan(span);
487            }
488        }
489    }
490}
491