AndroidSpellCheckerService.java revision f0e12a969974987f1b97929886c6ebe6a685c538
1/*
2 * Copyright (C) 2011 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License"); you may not
5 * use this file except in compliance with the License. You may obtain a copy of
6 * 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, WITHOUT
12 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13 * License for the specific language governing permissions and limitations under
14 * the License.
15 */
16
17package com.android.inputmethod.latin.spellcheck;
18
19import android.content.Intent;
20import android.content.SharedPreferences;
21import android.preference.PreferenceManager;
22import android.service.textservice.SpellCheckerService;
23import android.text.TextUtils;
24import android.util.Log;
25import android.util.LruCache;
26import android.view.textservice.SentenceSuggestionsInfo;
27import android.view.textservice.SuggestionsInfo;
28import android.view.textservice.TextInfo;
29
30import com.android.inputmethod.compat.SuggestionsInfoCompatUtils;
31import com.android.inputmethod.keyboard.ProximityInfo;
32import com.android.inputmethod.latin.BinaryDictionary;
33import com.android.inputmethod.latin.Dictionary;
34import com.android.inputmethod.latin.Dictionary.WordCallback;
35import com.android.inputmethod.latin.DictionaryCollection;
36import com.android.inputmethod.latin.DictionaryFactory;
37import com.android.inputmethod.latin.LatinIME;
38import com.android.inputmethod.latin.LocaleUtils;
39import com.android.inputmethod.latin.R;
40import com.android.inputmethod.latin.StringUtils;
41import com.android.inputmethod.latin.SynchronouslyLoadedContactsBinaryDictionary;
42import com.android.inputmethod.latin.SynchronouslyLoadedContactsDictionary;
43import com.android.inputmethod.latin.SynchronouslyLoadedUserDictionary;
44import com.android.inputmethod.latin.WhitelistDictionary;
45import com.android.inputmethod.latin.WordComposer;
46
47import java.lang.ref.WeakReference;
48import java.util.ArrayList;
49import java.util.Arrays;
50import java.util.Collections;
51import java.util.HashSet;
52import java.util.Iterator;
53import java.util.Locale;
54import java.util.Map;
55import java.util.TreeMap;
56
57/**
58 * Service for spell checking, using LatinIME's dictionaries and mechanisms.
59 */
60public class AndroidSpellCheckerService extends SpellCheckerService
61        implements SharedPreferences.OnSharedPreferenceChangeListener {
62    private static final String TAG = AndroidSpellCheckerService.class.getSimpleName();
63    private static final boolean DBG = false;
64    private static final int POOL_SIZE = 2;
65
66    public static final String PREF_USE_CONTACTS_KEY = "pref_spellcheck_use_contacts";
67
68    private static final int CAPITALIZE_NONE = 0; // No caps, or mixed case
69    private static final int CAPITALIZE_FIRST = 1; // First only
70    private static final int CAPITALIZE_ALL = 2; // All caps
71
72    private final static String[] EMPTY_STRING_ARRAY = new String[0];
73    private Map<String, DictionaryPool> mDictionaryPools =
74            Collections.synchronizedMap(new TreeMap<String, DictionaryPool>());
75    private Map<String, Dictionary> mUserDictionaries =
76            Collections.synchronizedMap(new TreeMap<String, Dictionary>());
77    private Map<String, Dictionary> mWhitelistDictionaries =
78            Collections.synchronizedMap(new TreeMap<String, Dictionary>());
79    private Dictionary mContactsDictionary;
80
81    // The threshold for a candidate to be offered as a suggestion.
82    private double mSuggestionThreshold;
83    // The threshold for a suggestion to be considered "recommended".
84    private double mRecommendedThreshold;
85    // Whether to use the contacts dictionary
86    private boolean mUseContactsDictionary;
87    private final Object mUseContactsLock = new Object();
88
89    private final HashSet<WeakReference<DictionaryCollection>> mDictionaryCollectionsList =
90            new HashSet<WeakReference<DictionaryCollection>>();
91
92    public static final int SCRIPT_LATIN = 0;
93    public static final int SCRIPT_CYRILLIC = 1;
94    private static final String SINGLE_QUOTE = "\u0027";
95    private static final String APOSTROPHE = "\u2019";
96    private static final TreeMap<String, Integer> mLanguageToScript;
97    static {
98        // List of the supported languages and their associated script. We won't check
99        // words written in another script than the selected script, because we know we
100        // don't have those in our dictionary so we will underline everything and we
101        // will never have any suggestions, so it makes no sense checking them.
102        mLanguageToScript = new TreeMap<String, Integer>();
103        mLanguageToScript.put("en", SCRIPT_LATIN);
104        mLanguageToScript.put("fr", SCRIPT_LATIN);
105        mLanguageToScript.put("de", SCRIPT_LATIN);
106        mLanguageToScript.put("nl", SCRIPT_LATIN);
107        mLanguageToScript.put("cs", SCRIPT_LATIN);
108        mLanguageToScript.put("es", SCRIPT_LATIN);
109        mLanguageToScript.put("it", SCRIPT_LATIN);
110        mLanguageToScript.put("ru", SCRIPT_CYRILLIC);
111    }
112
113    @Override public void onCreate() {
114        super.onCreate();
115        mSuggestionThreshold =
116                Double.parseDouble(getString(R.string.spellchecker_suggestion_threshold_value));
117        mRecommendedThreshold =
118                Double.parseDouble(getString(R.string.spellchecker_recommended_threshold_value));
119        final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
120        prefs.registerOnSharedPreferenceChangeListener(this);
121        onSharedPreferenceChanged(prefs, PREF_USE_CONTACTS_KEY);
122    }
123
124    private static int getScriptFromLocale(final Locale locale) {
125        final Integer script = mLanguageToScript.get(locale.getLanguage());
126        if (null == script) {
127            throw new RuntimeException("We have been called with an unsupported language: \""
128                    + locale.getLanguage() + "\". Framework bug?");
129        }
130        return script;
131    }
132
133    @Override
134    public void onSharedPreferenceChanged(final SharedPreferences prefs, final String key) {
135        if (!PREF_USE_CONTACTS_KEY.equals(key)) return;
136        synchronized(mUseContactsLock) {
137            mUseContactsDictionary = prefs.getBoolean(PREF_USE_CONTACTS_KEY, true);
138            if (mUseContactsDictionary) {
139                startUsingContactsDictionaryLocked();
140            } else {
141                stopUsingContactsDictionaryLocked();
142            }
143        }
144    }
145
146    private void startUsingContactsDictionaryLocked() {
147        if (null == mContactsDictionary) {
148            mContactsDictionary = new SynchronouslyLoadedContactsDictionary(this);
149        }
150        final Iterator<WeakReference<DictionaryCollection>> iterator =
151                mDictionaryCollectionsList.iterator();
152        while (iterator.hasNext()) {
153            final WeakReference<DictionaryCollection> dictRef = iterator.next();
154            final DictionaryCollection dict = dictRef.get();
155            if (null == dict) {
156                iterator.remove();
157            } else {
158                dict.addDictionary(mContactsDictionary);
159            }
160        }
161    }
162
163    private void stopUsingContactsDictionaryLocked() {
164        if (null == mContactsDictionary) return;
165        final Dictionary contactsDict = mContactsDictionary;
166        // TODO: revert to the concrete type when USE_BINARY_CONTACTS_DICTIONARY is no longer needed
167        mContactsDictionary = null;
168        final Iterator<WeakReference<DictionaryCollection>> iterator =
169                mDictionaryCollectionsList.iterator();
170        while (iterator.hasNext()) {
171            final WeakReference<DictionaryCollection> dictRef = iterator.next();
172            final DictionaryCollection dict = dictRef.get();
173            if (null == dict) {
174                iterator.remove();
175            } else {
176                dict.removeDictionary(contactsDict);
177            }
178        }
179        contactsDict.close();
180    }
181
182    @Override
183    public Session createSession() {
184        return new AndroidSpellCheckerSession(this);
185    }
186
187    private static SuggestionsInfo getNotInDictEmptySuggestions() {
188        return new SuggestionsInfo(0, EMPTY_STRING_ARRAY);
189    }
190
191    private static SuggestionsInfo getInDictEmptySuggestions() {
192        return new SuggestionsInfo(SuggestionsInfo.RESULT_ATTR_IN_THE_DICTIONARY,
193                EMPTY_STRING_ARRAY);
194    }
195
196    private static class SuggestionsGatherer implements WordCallback {
197        public static class Result {
198            public final String[] mSuggestions;
199            public final boolean mHasRecommendedSuggestions;
200            public Result(final String[] gatheredSuggestions,
201                    final boolean hasRecommendedSuggestions) {
202                mSuggestions = gatheredSuggestions;
203                mHasRecommendedSuggestions = hasRecommendedSuggestions;
204            }
205        }
206
207        private final ArrayList<CharSequence> mSuggestions;
208        private final int[] mScores;
209        private final String mOriginalText;
210        private final double mSuggestionThreshold;
211        private final double mRecommendedThreshold;
212        private final int mMaxLength;
213        private int mLength = 0;
214
215        // The two following attributes are only ever filled if the requested max length
216        // is 0 (or less, which is treated the same).
217        private String mBestSuggestion = null;
218        private int mBestScore = Integer.MIN_VALUE; // As small as possible
219
220        SuggestionsGatherer(final String originalText, final double suggestionThreshold,
221                final double recommendedThreshold, final int maxLength) {
222            mOriginalText = originalText;
223            mSuggestionThreshold = suggestionThreshold;
224            mRecommendedThreshold = recommendedThreshold;
225            mMaxLength = maxLength;
226            mSuggestions = new ArrayList<CharSequence>(maxLength + 1);
227            mScores = new int[mMaxLength];
228        }
229
230        @Override
231        synchronized public boolean addWord(char[] word, int wordOffset, int wordLength, int score,
232                int dicTypeId, int dataType) {
233            final int positionIndex = Arrays.binarySearch(mScores, 0, mLength, score);
234            // binarySearch returns the index if the element exists, and -<insertion index> - 1
235            // if it doesn't. See documentation for binarySearch.
236            final int insertIndex = positionIndex >= 0 ? positionIndex : -positionIndex - 1;
237
238            if (insertIndex == 0 && mLength >= mMaxLength) {
239                // In the future, we may want to keep track of the best suggestion score even if
240                // we are asked for 0 suggestions. In this case, we can use the following
241                // (tested) code to keep it:
242                // If the maxLength is 0 (should never be less, but if it is, it's treated as 0)
243                // then we need to keep track of the best suggestion in mBestScore and
244                // mBestSuggestion. This is so that we know whether the best suggestion makes
245                // the score cutoff, since we need to know that to return a meaningful
246                // looksLikeTypo.
247                // if (0 >= mMaxLength) {
248                //     if (score > mBestScore) {
249                //         mBestScore = score;
250                //         mBestSuggestion = new String(word, wordOffset, wordLength);
251                //     }
252                // }
253                return true;
254            }
255            if (insertIndex >= mMaxLength) {
256                // We found a suggestion, but its score is too weak to be kept considering
257                // the suggestion limit.
258                return true;
259            }
260
261            // Compute the normalized score and skip this word if it's normalized score does not
262            // make the threshold.
263            final String wordString = new String(word, wordOffset, wordLength);
264            final double normalizedScore =
265                    BinaryDictionary.calcNormalizedScore(mOriginalText, wordString, score);
266            if (normalizedScore < mSuggestionThreshold) {
267                if (DBG) Log.i(TAG, wordString + " does not make the score threshold");
268                return true;
269            }
270
271            if (mLength < mMaxLength) {
272                final int copyLen = mLength - insertIndex;
273                ++mLength;
274                System.arraycopy(mScores, insertIndex, mScores, insertIndex + 1, copyLen);
275                mSuggestions.add(insertIndex, wordString);
276            } else {
277                System.arraycopy(mScores, 1, mScores, 0, insertIndex);
278                mSuggestions.add(insertIndex, wordString);
279                mSuggestions.remove(0);
280            }
281            mScores[insertIndex] = score;
282
283            return true;
284        }
285
286        public Result getResults(final int capitalizeType, final Locale locale) {
287            final String[] gatheredSuggestions;
288            final boolean hasRecommendedSuggestions;
289            if (0 == mLength) {
290                // Either we found no suggestions, or we found some BUT the max length was 0.
291                // If we found some mBestSuggestion will not be null. If it is null, then
292                // we found none, regardless of the max length.
293                if (null == mBestSuggestion) {
294                    gatheredSuggestions = null;
295                    hasRecommendedSuggestions = false;
296                } else {
297                    gatheredSuggestions = EMPTY_STRING_ARRAY;
298                    final double normalizedScore = BinaryDictionary.calcNormalizedScore(
299                            mOriginalText, mBestSuggestion, mBestScore);
300                    hasRecommendedSuggestions = (normalizedScore > mRecommendedThreshold);
301                }
302            } else {
303                if (DBG) {
304                    if (mLength != mSuggestions.size()) {
305                        Log.e(TAG, "Suggestion size is not the same as stored mLength");
306                    }
307                    for (int i = mLength - 1; i >= 0; --i) {
308                        Log.i(TAG, "" + mScores[i] + " " + mSuggestions.get(i));
309                    }
310                }
311                Collections.reverse(mSuggestions);
312                StringUtils.removeDupes(mSuggestions);
313                if (CAPITALIZE_ALL == capitalizeType) {
314                    for (int i = 0; i < mSuggestions.size(); ++i) {
315                        // get(i) returns a CharSequence which is actually a String so .toString()
316                        // should return the same object.
317                        mSuggestions.set(i, mSuggestions.get(i).toString().toUpperCase(locale));
318                    }
319                } else if (CAPITALIZE_FIRST == capitalizeType) {
320                    for (int i = 0; i < mSuggestions.size(); ++i) {
321                        // Likewise
322                        mSuggestions.set(i, StringUtils.toTitleCase(
323                                mSuggestions.get(i).toString(), locale));
324                    }
325                }
326                // This returns a String[], while toArray() returns an Object[] which cannot be cast
327                // into a String[].
328                gatheredSuggestions = mSuggestions.toArray(EMPTY_STRING_ARRAY);
329
330                final int bestScore = mScores[mLength - 1];
331                final CharSequence bestSuggestion = mSuggestions.get(0);
332                final double normalizedScore =
333                        BinaryDictionary.calcNormalizedScore(
334                                mOriginalText, bestSuggestion.toString(), bestScore);
335                hasRecommendedSuggestions = (normalizedScore > mRecommendedThreshold);
336                if (DBG) {
337                    Log.i(TAG, "Best suggestion : " + bestSuggestion + ", score " + bestScore);
338                    Log.i(TAG, "Normalized score = " + normalizedScore
339                            + " (threshold " + mRecommendedThreshold
340                            + ") => hasRecommendedSuggestions = " + hasRecommendedSuggestions);
341                }
342            }
343            return new Result(gatheredSuggestions, hasRecommendedSuggestions);
344        }
345    }
346
347    @Override
348    public boolean onUnbind(final Intent intent) {
349        closeAllDictionaries();
350        return false;
351    }
352
353    private void closeAllDictionaries() {
354        final Map<String, DictionaryPool> oldPools = mDictionaryPools;
355        mDictionaryPools = Collections.synchronizedMap(new TreeMap<String, DictionaryPool>());
356        final Map<String, Dictionary> oldUserDictionaries = mUserDictionaries;
357        mUserDictionaries = Collections.synchronizedMap(new TreeMap<String, Dictionary>());
358        final Map<String, Dictionary> oldWhitelistDictionaries = mWhitelistDictionaries;
359        mWhitelistDictionaries = Collections.synchronizedMap(new TreeMap<String, Dictionary>());
360        for (DictionaryPool pool : oldPools.values()) {
361            pool.close();
362        }
363        for (Dictionary dict : oldUserDictionaries.values()) {
364            dict.close();
365        }
366        for (Dictionary dict : oldWhitelistDictionaries.values()) {
367            dict.close();
368        }
369        synchronized (mUseContactsLock) {
370            if (null != mContactsDictionary) {
371                // The synchronously loaded contacts dictionary should have been in one
372                // or several pools, but it is shielded against multiple closing and it's
373                // safe to call it several times.
374                final Dictionary dictToClose = mContactsDictionary;
375                // TODO: revert to the concrete type when USE_BINARY_CONTACTS_DICTIONARY is no
376                // longer needed
377                mContactsDictionary = null;
378                dictToClose.close();
379            }
380        }
381    }
382
383    private DictionaryPool getDictionaryPool(final String locale) {
384        DictionaryPool pool = mDictionaryPools.get(locale);
385        if (null == pool) {
386            final Locale localeObject = LocaleUtils.constructLocaleFromString(locale);
387            pool = new DictionaryPool(POOL_SIZE, this, localeObject);
388            mDictionaryPools.put(locale, pool);
389        }
390        return pool;
391    }
392
393    public DictAndProximity createDictAndProximity(final Locale locale) {
394        final int script = getScriptFromLocale(locale);
395        final ProximityInfo proximityInfo = ProximityInfo.createSpellCheckerProximityInfo(
396                SpellCheckerProximityInfo.getProximityForScript(script),
397                SpellCheckerProximityInfo.ROW_SIZE,
398                SpellCheckerProximityInfo.PROXIMITY_GRID_WIDTH,
399                SpellCheckerProximityInfo.PROXIMITY_GRID_HEIGHT);
400        final DictionaryCollection dictionaryCollection =
401                DictionaryFactory.createMainDictionaryFromManager(this, locale,
402                        true /* useFullEditDistance */);
403        final String localeStr = locale.toString();
404        Dictionary userDictionary = mUserDictionaries.get(localeStr);
405        if (null == userDictionary) {
406            userDictionary = new SynchronouslyLoadedUserDictionary(this, localeStr, true);
407            mUserDictionaries.put(localeStr, userDictionary);
408        }
409        dictionaryCollection.addDictionary(userDictionary);
410        Dictionary whitelistDictionary = mWhitelistDictionaries.get(localeStr);
411        if (null == whitelistDictionary) {
412            whitelistDictionary = new WhitelistDictionary(this, locale);
413            mWhitelistDictionaries.put(localeStr, whitelistDictionary);
414        }
415        dictionaryCollection.addDictionary(whitelistDictionary);
416        synchronized (mUseContactsLock) {
417            if (mUseContactsDictionary) {
418                if (null == mContactsDictionary) {
419                    // TODO: revert to the concrete type when USE_BINARY_CONTACTS_DICTIONARY is no
420                    // longer needed
421                    if (LatinIME.USE_BINARY_CONTACTS_DICTIONARY) {
422                        mContactsDictionary = new SynchronouslyLoadedContactsBinaryDictionary(this);
423                    } else {
424                        mContactsDictionary = new SynchronouslyLoadedContactsDictionary(this);
425                    }
426                }
427            }
428            dictionaryCollection.addDictionary(mContactsDictionary);
429            mDictionaryCollectionsList.add(
430                    new WeakReference<DictionaryCollection>(dictionaryCollection));
431        }
432        return new DictAndProximity(dictionaryCollection, proximityInfo);
433    }
434
435    // This method assumes the text is not empty or null.
436    private static int getCapitalizationType(String text) {
437        // If the first char is not uppercase, then the word is either all lower case,
438        // and in either case we return CAPITALIZE_NONE.
439        if (!Character.isUpperCase(text.codePointAt(0))) return CAPITALIZE_NONE;
440        final int len = text.length();
441        int capsCount = 1;
442        for (int i = 1; i < len; i = text.offsetByCodePoints(i, 1)) {
443            if (1 != capsCount && i != capsCount) break;
444            if (Character.isUpperCase(text.codePointAt(i))) ++capsCount;
445        }
446        // We know the first char is upper case. So we want to test if either everything
447        // else is lower case, or if everything else is upper case. If the string is
448        // exactly one char long, then we will arrive here with capsCount 1, and this is
449        // correct, too.
450        if (1 == capsCount) return CAPITALIZE_FIRST;
451        return (len == capsCount ? CAPITALIZE_ALL : CAPITALIZE_NONE);
452    }
453
454    private static class AndroidSpellCheckerSession extends Session {
455        // Immutable, but need the locale which is not available in the constructor yet
456        private DictionaryPool mDictionaryPool;
457        // Likewise
458        private Locale mLocale;
459        // Cache this for performance
460        private int mScript; // One of SCRIPT_LATIN or SCRIPT_CYRILLIC for now.
461
462        private final AndroidSpellCheckerService mService;
463
464        private final SuggestionsCache mSuggestionsCache = new SuggestionsCache();
465
466        private static class SuggestionsParams {
467            public final String[] mSuggestions;
468            public final int mFlags;
469            public SuggestionsParams(String[] suggestions, int flags) {
470                mSuggestions = suggestions;
471                mFlags = flags;
472            }
473        }
474
475        private static class SuggestionsCache {
476            private static final int MAX_CACHE_SIZE = 50;
477            // TODO: support bigram
478            private final LruCache<String, SuggestionsParams> mUnigramSuggestionsInfoCache =
479                    new LruCache<String, SuggestionsParams>(MAX_CACHE_SIZE);
480
481            public SuggestionsParams getSuggestionsFromCache(String query) {
482                return mUnigramSuggestionsInfoCache.get(query);
483            }
484
485            public void putSuggestionsToCache(String query, String[] suggestions, int flags) {
486                if (suggestions == null || TextUtils.isEmpty(query)) {
487                    return;
488                }
489                mUnigramSuggestionsInfoCache.put(query, new SuggestionsParams(suggestions, flags));
490            }
491
492            public void remove(String key) {
493                mUnigramSuggestionsInfoCache.remove(key);
494            }
495        }
496
497        AndroidSpellCheckerSession(final AndroidSpellCheckerService service) {
498            mService = service;
499        }
500
501        @Override
502        public void onCreate() {
503            final String localeString = getLocale();
504            mDictionaryPool = mService.getDictionaryPool(localeString);
505            mLocale = LocaleUtils.constructLocaleFromString(localeString);
506            mScript = getScriptFromLocale(mLocale);
507        }
508
509        /*
510         * Returns whether the code point is a letter that makes sense for the specified
511         * locale for this spell checker.
512         * The dictionaries supported by Latin IME are described in res/xml/spellchecker.xml
513         * and is limited to EFIGS languages and Russian.
514         * Hence at the moment this explicitly tests for Cyrillic characters or Latin characters
515         * as appropriate, and explicitly excludes CJK, Arabic and Hebrew characters.
516         */
517        private static boolean isLetterCheckableByLanguage(final int codePoint,
518                final int script) {
519            switch (script) {
520            case SCRIPT_LATIN:
521                // Our supported latin script dictionaries (EFIGS) at the moment only include
522                // characters in the C0, C1, Latin Extended A and B, IPA extensions unicode
523                // blocks. As it happens, those are back-to-back in the code range 0x40 to 0x2AF,
524                // so the below is a very efficient way to test for it. As for the 0-0x3F, it's
525                // excluded from isLetter anyway.
526                return codePoint <= 0x2AF && Character.isLetter(codePoint);
527            case SCRIPT_CYRILLIC:
528                // All Cyrillic characters are in the 400~52F block. There are some in the upper
529                // Unicode range, but they are archaic characters that are not used in modern
530                // russian and are not used by our dictionary.
531                return codePoint >= 0x400 && codePoint <= 0x52F && Character.isLetter(codePoint);
532            default:
533                // Should never come here
534                throw new RuntimeException("Impossible value of script: " + script);
535            }
536        }
537
538        /**
539         * Finds out whether a particular string should be filtered out of spell checking.
540         *
541         * This will loosely match URLs, numbers, symbols. To avoid always underlining words that
542         * we know we will never recognize, this accepts a script identifier that should be one
543         * of the SCRIPT_* constants defined above, to rule out quickly characters from very
544         * different languages.
545         *
546         * @param text the string to evaluate.
547         * @param script the identifier for the script this spell checker recognizes
548         * @return true if we should filter this text out, false otherwise
549         */
550        private static boolean shouldFilterOut(final String text, final int script) {
551            if (TextUtils.isEmpty(text) || text.length() <= 1) return true;
552
553            // TODO: check if an equivalent processing can't be done more quickly with a
554            // compiled regexp.
555            // Filter by first letter
556            final int firstCodePoint = text.codePointAt(0);
557            // Filter out words that don't start with a letter or an apostrophe
558            if (!isLetterCheckableByLanguage(firstCodePoint, script)
559                    && '\'' != firstCodePoint) return true;
560
561            // Filter contents
562            final int length = text.length();
563            int letterCount = 0;
564            for (int i = 0; i < length; i = text.offsetByCodePoints(i, 1)) {
565                final int codePoint = text.codePointAt(i);
566                // Any word containing a '@' is probably an e-mail address
567                // Any word containing a '/' is probably either an ad-hoc combination of two
568                // words or a URI - in either case we don't want to spell check that
569                if ('@' == codePoint || '/' == codePoint) return true;
570                if (isLetterCheckableByLanguage(codePoint, script)) ++letterCount;
571            }
572            // Guestimate heuristic: perform spell checking if at least 3/4 of the characters
573            // in this word are letters
574            return (letterCount * 4 < length * 3);
575        }
576
577        private SentenceSuggestionsInfo fixWronglyInvalidatedWordWithSingleQuote(
578                TextInfo ti, SentenceSuggestionsInfo ssi) {
579            final String typedText = ti.getText();
580            if (!typedText.contains(SINGLE_QUOTE)) {
581                return null;
582            }
583            final int N = ssi.getSuggestionsCount();
584            final ArrayList<Integer> additionalOffsets = new ArrayList<Integer>();
585            final ArrayList<Integer> additionalLengths = new ArrayList<Integer>();
586            final ArrayList<SuggestionsInfo> additionalSuggestionsInfos =
587                    new ArrayList<SuggestionsInfo>();
588            for (int i = 0; i < N; ++i) {
589                final SuggestionsInfo si = ssi.getSuggestionsInfoAt(i);
590                final int flags = si.getSuggestionsAttributes();
591                if ((flags & SuggestionsInfo.RESULT_ATTR_IN_THE_DICTIONARY) == 0) {
592                    continue;
593                }
594                final int offset = ssi.getOffsetAt(i);
595                final int length = ssi.getLengthAt(i);
596                final String subText = typedText.substring(offset, offset + length);
597                if (!subText.contains(SINGLE_QUOTE)) {
598                    continue;
599                }
600                final String[] splitTexts = subText.split(SINGLE_QUOTE, -1);
601                if (splitTexts == null || splitTexts.length <= 1) {
602                    continue;
603                }
604                final int splitNum = splitTexts.length;
605                for (int j = 0; j < splitNum; ++j) {
606                    final String splitText = splitTexts[j];
607                    if (TextUtils.isEmpty(splitText)) {
608                        continue;
609                    }
610                    if (mSuggestionsCache.getSuggestionsFromCache(splitText) == null) {
611                        continue;
612                    }
613                    final int newLength = splitText.length();
614                    // Neither RESULT_ATTR_IN_THE_DICTIONARY nor RESULT_ATTR_LOOKS_LIKE_TYPO
615                    final int newFlags = 0;
616                    final SuggestionsInfo newSi = new SuggestionsInfo(newFlags, EMPTY_STRING_ARRAY);
617                    newSi.setCookieAndSequence(si.getCookie(), si.getSequence());
618                    if (DBG) {
619                        Log.d(TAG, "Override and remove old span over: "
620                                + splitText + ", " + offset + "," + newLength);
621                    }
622                    additionalOffsets.add(offset);
623                    additionalLengths.add(newLength);
624                    additionalSuggestionsInfos.add(newSi);
625                }
626            }
627            final int additionalSize = additionalOffsets.size();
628            if (additionalSize <= 0) {
629                return null;
630            }
631            final int suggestionsSize = N + additionalSize;
632            final int[] newOffsets = new int[suggestionsSize];
633            final int[] newLengths = new int[suggestionsSize];
634            final SuggestionsInfo[] newSuggestionsInfos = new SuggestionsInfo[suggestionsSize];
635            int i;
636            for (i = 0; i < N; ++i) {
637                newOffsets[i] = ssi.getOffsetAt(i);
638                newLengths[i] = ssi.getLengthAt(i);
639                newSuggestionsInfos[i] = ssi.getSuggestionsInfoAt(i);
640            }
641            for (; i < suggestionsSize; ++i) {
642                newOffsets[i] = additionalOffsets.get(i - N);
643                newLengths[i] = additionalLengths.get(i - N);
644                newSuggestionsInfos[i] = additionalSuggestionsInfos.get(i - N);
645            }
646            return new SentenceSuggestionsInfo(newSuggestionsInfos, newOffsets, newLengths);
647        }
648
649        @Override
650        public SentenceSuggestionsInfo[] onGetSentenceSuggestionsMultiple(
651                TextInfo[] textInfos, int suggestionsLimit) {
652            final SentenceSuggestionsInfo[] retval = super.onGetSentenceSuggestionsMultiple(
653                    textInfos, suggestionsLimit);
654            if (retval == null || retval.length != textInfos.length) {
655                return retval;
656            }
657            for (int i = 0; i < retval.length; ++i) {
658                final SentenceSuggestionsInfo tempSsi =
659                        fixWronglyInvalidatedWordWithSingleQuote(textInfos[i], retval[i]);
660                if (tempSsi != null) {
661                    retval[i] = tempSsi;
662                }
663            }
664            return retval;
665        }
666
667        // Note : this must be reentrant
668        /**
669         * Gets a list of suggestions for a specific string. This returns a list of possible
670         * corrections for the text passed as an argument. It may split or group words, and
671         * even perform grammatical analysis.
672         */
673        @Override
674        public SuggestionsInfo onGetSuggestions(final TextInfo textInfo,
675                final int suggestionsLimit) {
676            try {
677                final String inText = textInfo.getText();
678                final SuggestionsParams cachedSuggestionsParams =
679                        mSuggestionsCache.getSuggestionsFromCache(inText);
680                if (cachedSuggestionsParams != null) {
681                    if (DBG) {
682                        Log.d(TAG, "Cache hit: " + inText + ", " + cachedSuggestionsParams.mFlags);
683                    }
684                    return new SuggestionsInfo(
685                            cachedSuggestionsParams.mFlags, cachedSuggestionsParams.mSuggestions);
686                }
687
688                if (shouldFilterOut(inText, mScript)) {
689                    DictAndProximity dictInfo = null;
690                    try {
691                        dictInfo = mDictionaryPool.takeOrGetNull();
692                        if (null == dictInfo) return getNotInDictEmptySuggestions();
693                        return dictInfo.mDictionary.isValidWord(inText) ?
694                                getInDictEmptySuggestions() : getNotInDictEmptySuggestions();
695                    } finally {
696                        if (null != dictInfo) {
697                            if (!mDictionaryPool.offer(dictInfo)) {
698                                Log.e(TAG, "Can't re-insert a dictionary into its pool");
699                            }
700                        }
701                    }
702                }
703                final String text = inText.replaceAll(APOSTROPHE, SINGLE_QUOTE);
704
705                // TODO: Don't gather suggestions if the limit is <= 0 unless necessary
706                final SuggestionsGatherer suggestionsGatherer = new SuggestionsGatherer(text,
707                        mService.mSuggestionThreshold, mService.mRecommendedThreshold,
708                        suggestionsLimit);
709                final WordComposer composer = new WordComposer();
710                final int length = text.length();
711                for (int i = 0; i < length; i = text.offsetByCodePoints(i, 1)) {
712                    final int codePoint = text.codePointAt(i);
713                    // The getXYForCodePointAndScript method returns (Y << 16) + X
714                    final int xy = SpellCheckerProximityInfo.getXYForCodePointAndScript(
715                            codePoint, mScript);
716                    if (SpellCheckerProximityInfo.NOT_A_COORDINATE_PAIR == xy) {
717                        composer.add(codePoint, WordComposer.NOT_A_COORDINATE,
718                                WordComposer.NOT_A_COORDINATE, null);
719                    } else {
720                        composer.add(codePoint, xy & 0xFFFF, xy >> 16, null);
721                    }
722                }
723
724                final int capitalizeType = getCapitalizationType(text);
725                boolean isInDict = true;
726                DictAndProximity dictInfo = null;
727                try {
728                    dictInfo = mDictionaryPool.takeOrGetNull();
729                    if (null == dictInfo) return getNotInDictEmptySuggestions();
730                    dictInfo.mDictionary.getWords(composer, null, suggestionsGatherer,
731                            dictInfo.mProximityInfo);
732                    isInDict = dictInfo.mDictionary.isValidWord(text);
733                    if (!isInDict && CAPITALIZE_NONE != capitalizeType) {
734                        // We want to test the word again if it's all caps or first caps only.
735                        // If it's fully down, we already tested it, if it's mixed case, we don't
736                        // want to test a lowercase version of it.
737                        isInDict = dictInfo.mDictionary.isValidWord(text.toLowerCase(mLocale));
738                    }
739                } finally {
740                    if (null != dictInfo) {
741                        if (!mDictionaryPool.offer(dictInfo)) {
742                            Log.e(TAG, "Can't re-insert a dictionary into its pool");
743                        }
744                    }
745                }
746
747                final SuggestionsGatherer.Result result = suggestionsGatherer.getResults(
748                        capitalizeType, mLocale);
749
750                if (DBG) {
751                    Log.i(TAG, "Spell checking results for " + text + " with suggestion limit "
752                            + suggestionsLimit);
753                    Log.i(TAG, "IsInDict = " + isInDict);
754                    Log.i(TAG, "LooksLikeTypo = " + (!isInDict));
755                    Log.i(TAG, "HasRecommendedSuggestions = " + result.mHasRecommendedSuggestions);
756                    if (null != result.mSuggestions) {
757                        for (String suggestion : result.mSuggestions) {
758                            Log.i(TAG, suggestion);
759                        }
760                    }
761                }
762
763                final int flags =
764                        (isInDict ? SuggestionsInfo.RESULT_ATTR_IN_THE_DICTIONARY
765                                : SuggestionsInfo.RESULT_ATTR_LOOKS_LIKE_TYPO)
766                        | (result.mHasRecommendedSuggestions
767                                ? SuggestionsInfoCompatUtils
768                                        .getValueOf_RESULT_ATTR_HAS_RECOMMENDED_SUGGESTIONS()
769                                : 0);
770                final SuggestionsInfo retval = new SuggestionsInfo(flags, result.mSuggestions);
771                mSuggestionsCache.putSuggestionsToCache(text, result.mSuggestions, flags);
772                return retval;
773            } catch (RuntimeException e) {
774                // Don't kill the keyboard if there is a bug in the spell checker
775                if (DBG) {
776                    throw e;
777                } else {
778                    Log.e(TAG, "Exception while spellcheking: " + e);
779                    return getNotInDictEmptySuggestions();
780                }
781            }
782        }
783    }
784}
785