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