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