SpellChecker.java revision c08ec615d26508c14c44680ffe649d46be6de8c5
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                break;
254            }
255        }
256
257        final int suggestionsCount = suggestionsInfo.getSuggestionsCount();
258        String[] suggestions;
259        if (suggestionsCount <= 0) {
260            // A negative suggestion count is possible
261            suggestions = ArrayUtils.emptyArray(String.class);
262        } else {
263            int numberOfSuggestions = 0;
264            suggestions = new String[suggestionsCount];
265
266            for (int i = 0; i < suggestionsCount; i++) {
267                final String spellSuggestion = suggestionsInfo.getSuggestionAt(i);
268                if (spellSuggestion == null) break;
269                boolean suggestionFound = false;
270
271                for (int j = 0; j < length && !suggestionFound; j++) {
272                    if (suggestionSpans[j] == null) break;
273
274                    String[] suggests = suggestionSpans[j].getSuggestions();
275                    for (int k = 0; k < suggests.length; k++) {
276                        if (spellSuggestion.equals(suggests[k])) {
277                            // The suggestion is already provided by an other SuggestionSpan
278                            suggestionFound = true;
279                            break;
280                        }
281                    }
282                }
283
284                if (!suggestionFound) {
285                    suggestions[numberOfSuggestions++] = spellSuggestion;
286                }
287            }
288
289            if (numberOfSuggestions != suggestionsCount) {
290                String[] newSuggestions = new String[numberOfSuggestions];
291                System.arraycopy(suggestions, 0, newSuggestions, 0, numberOfSuggestions);
292                suggestions = newSuggestions;
293            }
294        }
295
296        SuggestionSpan suggestionSpan = new SuggestionSpan(mTextView.getContext(), suggestions,
297                SuggestionSpan.FLAG_EASY_CORRECT | SuggestionSpan.FLAG_MISSPELLED);
298        editable.setSpan(suggestionSpan, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
299
300        // TODO limit to the word rectangle region
301        mTextView.invalidate();
302    }
303
304    private class SpellParser {
305        private WordIterator mWordIterator = new WordIterator(/*TODO Locale*/);
306        private Object mRange = new Object();
307
308        public void init(int start, int end) {
309            ((Editable) mTextView.getText()).setSpan(mRange, start, end,
310                    Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
311        }
312
313        public void close() {
314            ((Editable) mTextView.getText()).removeSpan(mRange);
315        }
316
317        public boolean isDone() {
318            return ((Editable) mTextView.getText()).getSpanStart(mRange) < 0;
319        }
320
321        public void parse() {
322            Editable editable = (Editable) mTextView.getText();
323            // Iterate over the newly added text and schedule new SpellCheckSpans
324            final int start = editable.getSpanStart(mRange);
325            final int end = editable.getSpanEnd(mRange);
326            mWordIterator.setCharSequence(editable, start, end);
327
328            // Move back to the beginning of the current word, if any
329            int wordStart = mWordIterator.preceding(start);
330            int wordEnd;
331            if (wordStart == BreakIterator.DONE) {
332                wordEnd = mWordIterator.following(start);
333                if (wordEnd != BreakIterator.DONE) {
334                    wordStart = mWordIterator.getBeginning(wordEnd);
335                }
336            } else {
337                wordEnd = mWordIterator.getEnd(wordStart);
338            }
339            if (wordEnd == BreakIterator.DONE) {
340                editable.removeSpan(mRange);
341                return;
342            }
343
344            // We need to expand by one character because we want to include the spans that
345            // end/start at position start/end respectively.
346            SpellCheckSpan[] spellCheckSpans = editable.getSpans(start - 1, end + 1,
347                    SpellCheckSpan.class);
348            SuggestionSpan[] suggestionSpans = editable.getSpans(start - 1, end + 1,
349                    SuggestionSpan.class);
350
351            int nbWordsChecked = 0;
352            boolean scheduleOtherSpellCheck = false;
353
354            while (wordStart <= end) {
355                if (wordEnd >= start && wordEnd > wordStart) {
356                    // A new word has been created across the interval boundaries with this edit.
357                    // Previous spans (ended on start / started on end) removed, not valid anymore
358                    if (wordStart < start && wordEnd > start) {
359                        removeSpansAt(editable, start, spellCheckSpans);
360                        removeSpansAt(editable, start, suggestionSpans);
361                    }
362
363                    if (wordStart < end && wordEnd > end) {
364                        removeSpansAt(editable, end, spellCheckSpans);
365                        removeSpansAt(editable, end, suggestionSpans);
366                    }
367
368                    // Do not create new boundary spans if they already exist
369                    boolean createSpellCheckSpan = true;
370                    if (wordEnd == start) {
371                        for (int i = 0; i < spellCheckSpans.length; i++) {
372                            final int spanEnd = editable.getSpanEnd(spellCheckSpans[i]);
373                            if (spanEnd == start) {
374                                createSpellCheckSpan = false;
375                                break;
376                            }
377                        }
378                    }
379
380                    if (wordStart == end) {
381                        for (int i = 0; i < spellCheckSpans.length; i++) {
382                            final int spanStart = editable.getSpanStart(spellCheckSpans[i]);
383                            if (spanStart == end) {
384                                createSpellCheckSpan = false;
385                                break;
386                            }
387                        }
388                    }
389
390                    if (createSpellCheckSpan) {
391                        if (nbWordsChecked == MAX_SPELL_BATCH_SIZE) {
392                            scheduleOtherSpellCheck = true;
393                            break;
394                        }
395                        addSpellCheckSpan(editable, wordStart, wordEnd);
396                        nbWordsChecked++;
397                    }
398                }
399
400                // iterate word by word
401                wordEnd = mWordIterator.following(wordEnd);
402                if (wordEnd == BreakIterator.DONE) break;
403                wordStart = mWordIterator.getBeginning(wordEnd);
404                if (wordStart == BreakIterator.DONE) {
405                    break;
406                }
407            }
408
409            if (scheduleOtherSpellCheck) {
410                editable.setSpan(mRange, wordStart, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
411            } else {
412                editable.removeSpan(mRange);
413            }
414
415            spellCheck();
416        }
417
418        private <T> void removeSpansAt(Editable editable, int offset, T[] spans) {
419            final int length = spans.length;
420            for (int i = 0; i < length; i++) {
421                final T span = spans[i];
422                final int start = editable.getSpanStart(span);
423                if (start > offset) continue;
424                final int end = editable.getSpanEnd(span);
425                if (end < offset) continue;
426                editable.removeSpan(span);
427            }
428        }
429    }
430}
431