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