AndroidSpellCheckerService.java revision e784148ae6872942434eaa55ca32b4c6442cc8e8
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 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.InputType;
24import android.util.Log;
25import android.view.inputmethod.EditorInfo;
26import android.view.inputmethod.InputMethodSubtype;
27import android.view.textservice.SuggestionsInfo;
28
29import com.android.inputmethod.keyboard.KeyboardLayoutSet;
30import com.android.inputmethod.latin.BinaryDictionary;
31import com.android.inputmethod.latin.ContactsBinaryDictionary;
32import com.android.inputmethod.latin.Dictionary;
33import com.android.inputmethod.latin.DictionaryCollection;
34import com.android.inputmethod.latin.DictionaryFactory;
35import com.android.inputmethod.latin.R;
36import com.android.inputmethod.latin.SynchronouslyLoadedContactsBinaryDictionary;
37import com.android.inputmethod.latin.SynchronouslyLoadedUserBinaryDictionary;
38import com.android.inputmethod.latin.UserBinaryDictionary;
39import com.android.inputmethod.latin.utils.AdditionalSubtypeUtils;
40import com.android.inputmethod.latin.utils.BinaryDictionaryUtils;
41import com.android.inputmethod.latin.utils.CollectionUtils;
42import com.android.inputmethod.latin.utils.LocaleUtils;
43import com.android.inputmethod.latin.utils.StringUtils;
44
45import java.lang.ref.WeakReference;
46import java.util.ArrayList;
47import java.util.Arrays;
48import java.util.Collections;
49import java.util.HashSet;
50import java.util.Iterator;
51import java.util.Locale;
52import java.util.Map;
53import java.util.TreeMap;
54
55/**
56 * Service for spell checking, using LatinIME's dictionaries and mechanisms.
57 */
58public final class AndroidSpellCheckerService extends SpellCheckerService
59        implements SharedPreferences.OnSharedPreferenceChangeListener {
60    private static final String TAG = AndroidSpellCheckerService.class.getSimpleName();
61    private static final boolean DBG = false;
62    private static final int POOL_SIZE = 2;
63
64    public static final String PREF_USE_CONTACTS_KEY = "pref_spellcheck_use_contacts";
65
66    private static final int SPELLCHECKER_DUMMY_KEYBOARD_WIDTH = 480;
67    private static final int SPELLCHECKER_DUMMY_KEYBOARD_HEIGHT = 368;
68
69    private final static String[] EMPTY_STRING_ARRAY = new String[0];
70    private Map<String, DictionaryPool> mDictionaryPools = CollectionUtils.newSynchronizedTreeMap();
71    private Map<String, UserBinaryDictionary> mUserDictionaries =
72            CollectionUtils.newSynchronizedTreeMap();
73    private ContactsBinaryDictionary mContactsDictionary;
74
75    // The threshold for a suggestion to be considered "recommended".
76    private float mRecommendedThreshold;
77    // Whether to use the contacts dictionary
78    private boolean mUseContactsDictionary;
79    private final Object mUseContactsLock = new Object();
80
81    private final HashSet<WeakReference<DictionaryCollection>> mDictionaryCollectionsList =
82            CollectionUtils.newHashSet();
83
84    public static final int SCRIPT_LATIN = 0;
85    public static final int SCRIPT_CYRILLIC = 1;
86    public static final int SCRIPT_GREEK = 2;
87    public static final String SINGLE_QUOTE = "\u0027";
88    public static final String APOSTROPHE = "\u2019";
89    private static final TreeMap<String, Integer> mLanguageToScript;
90    static {
91        // List of the supported languages and their associated script. We won't check
92        // words written in another script than the selected script, because we know we
93        // don't have those in our dictionary so we will underline everything and we
94        // will never have any suggestions, so it makes no sense checking them, and this
95        // is done in {@link #shouldFilterOut}. Also, the script is used to choose which
96        // proximity to pass to the dictionary descent algorithm.
97        // IMPORTANT: this only contains languages - do not write countries in there.
98        // Only the language is searched from the map.
99        mLanguageToScript = CollectionUtils.newTreeMap();
100        mLanguageToScript.put("cs", SCRIPT_LATIN);
101        mLanguageToScript.put("da", SCRIPT_LATIN);
102        mLanguageToScript.put("de", SCRIPT_LATIN);
103        mLanguageToScript.put("el", SCRIPT_GREEK);
104        mLanguageToScript.put("en", SCRIPT_LATIN);
105        mLanguageToScript.put("es", SCRIPT_LATIN);
106        mLanguageToScript.put("fi", SCRIPT_LATIN);
107        mLanguageToScript.put("fr", SCRIPT_LATIN);
108        mLanguageToScript.put("hr", SCRIPT_LATIN);
109        mLanguageToScript.put("it", SCRIPT_LATIN);
110        mLanguageToScript.put("lt", SCRIPT_LATIN);
111        mLanguageToScript.put("lv", SCRIPT_LATIN);
112        mLanguageToScript.put("nb", SCRIPT_LATIN);
113        mLanguageToScript.put("nl", SCRIPT_LATIN);
114        mLanguageToScript.put("pt", SCRIPT_LATIN);
115        mLanguageToScript.put("sl", SCRIPT_LATIN);
116        mLanguageToScript.put("ru", SCRIPT_CYRILLIC);
117    }
118
119    @Override public void onCreate() {
120        super.onCreate();
121        mRecommendedThreshold =
122                Float.parseFloat(getString(R.string.spellchecker_recommended_threshold_value));
123        final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
124        prefs.registerOnSharedPreferenceChangeListener(this);
125        onSharedPreferenceChanged(prefs, PREF_USE_CONTACTS_KEY);
126    }
127
128    public static int getScriptFromLocale(final Locale locale) {
129        final Integer script = mLanguageToScript.get(locale.getLanguage());
130        if (null == script) {
131            throw new RuntimeException("We have been called with an unsupported language: \""
132                    + locale.getLanguage() + "\". Framework bug?");
133        }
134        return script;
135    }
136
137    private static String getKeyboardLayoutNameForScript(final int script) {
138        switch (script) {
139        case AndroidSpellCheckerService.SCRIPT_LATIN:
140            return "qwerty";
141        case AndroidSpellCheckerService.SCRIPT_CYRILLIC:
142            return "east_slavic";
143        case AndroidSpellCheckerService.SCRIPT_GREEK:
144            return "greek";
145        default:
146            throw new RuntimeException("Wrong script supplied: " + script);
147        }
148    }
149
150    @Override
151    public void onSharedPreferenceChanged(final SharedPreferences prefs, final String key) {
152        if (!PREF_USE_CONTACTS_KEY.equals(key)) return;
153        synchronized(mUseContactsLock) {
154            mUseContactsDictionary = prefs.getBoolean(PREF_USE_CONTACTS_KEY, true);
155            if (mUseContactsDictionary) {
156                startUsingContactsDictionaryLocked();
157            } else {
158                stopUsingContactsDictionaryLocked();
159            }
160        }
161    }
162
163    private void startUsingContactsDictionaryLocked() {
164        if (null == mContactsDictionary) {
165            // TODO: use the right locale for each session
166            mContactsDictionary =
167                    new SynchronouslyLoadedContactsBinaryDictionary(this, Locale.getDefault());
168        }
169        final Iterator<WeakReference<DictionaryCollection>> iterator =
170                mDictionaryCollectionsList.iterator();
171        while (iterator.hasNext()) {
172            final WeakReference<DictionaryCollection> dictRef = iterator.next();
173            final DictionaryCollection dict = dictRef.get();
174            if (null == dict) {
175                iterator.remove();
176            } else {
177                dict.addDictionary(mContactsDictionary);
178            }
179        }
180    }
181
182    private void stopUsingContactsDictionaryLocked() {
183        if (null == mContactsDictionary) return;
184        final Dictionary contactsDict = mContactsDictionary;
185        // TODO: revert to the concrete type when USE_BINARY_CONTACTS_DICTIONARY is no longer needed
186        mContactsDictionary = null;
187        final Iterator<WeakReference<DictionaryCollection>> iterator =
188                mDictionaryCollectionsList.iterator();
189        while (iterator.hasNext()) {
190            final WeakReference<DictionaryCollection> dictRef = iterator.next();
191            final DictionaryCollection dict = dictRef.get();
192            if (null == dict) {
193                iterator.remove();
194            } else {
195                dict.removeDictionary(contactsDict);
196            }
197        }
198        contactsDict.close();
199    }
200
201    @Override
202    public Session createSession() {
203        // Should not refer to AndroidSpellCheckerSession directly considering
204        // that AndroidSpellCheckerSession may be overlaid.
205        return AndroidSpellCheckerSessionFactory.newInstance(this);
206    }
207
208    /**
209     * Returns an empty SuggestionsInfo with flags signaling the word is not in the dictionary.
210     * @param reportAsTypo whether this should include the flag LOOKS_LIKE_TYPO, for red underline.
211     * @return the empty SuggestionsInfo with the appropriate flags set.
212     */
213    public static SuggestionsInfo getNotInDictEmptySuggestions(final boolean reportAsTypo) {
214        return new SuggestionsInfo(reportAsTypo ? SuggestionsInfo.RESULT_ATTR_LOOKS_LIKE_TYPO : 0,
215                EMPTY_STRING_ARRAY);
216    }
217
218    /**
219     * Returns an empty suggestionInfo with flags signaling the word is in the dictionary.
220     * @return the empty SuggestionsInfo with the appropriate flags set.
221     */
222    public static SuggestionsInfo getInDictEmptySuggestions() {
223        return new SuggestionsInfo(SuggestionsInfo.RESULT_ATTR_IN_THE_DICTIONARY,
224                EMPTY_STRING_ARRAY);
225    }
226
227    public SuggestionsGatherer newSuggestionsGatherer(final String text, int maxLength) {
228        return new SuggestionsGatherer(text, mRecommendedThreshold, maxLength);
229    }
230
231    // TODO: remove this class and replace it by storage local to the session.
232    public static final class SuggestionsGatherer {
233        public static final class Result {
234            public final String[] mSuggestions;
235            public final boolean mHasRecommendedSuggestions;
236            public Result(final String[] gatheredSuggestions,
237                    final boolean hasRecommendedSuggestions) {
238                mSuggestions = gatheredSuggestions;
239                mHasRecommendedSuggestions = hasRecommendedSuggestions;
240            }
241        }
242
243        private final ArrayList<String> mSuggestions;
244        private final int[] mScores;
245        private final String mOriginalText;
246        private final float mRecommendedThreshold;
247        private final int mMaxLength;
248        private int mLength = 0;
249
250        // The two following attributes are only ever filled if the requested max length
251        // is 0 (or less, which is treated the same).
252        private String mBestSuggestion = null;
253        private int mBestScore = Integer.MIN_VALUE; // As small as possible
254
255        SuggestionsGatherer(final String originalText, final float recommendedThreshold,
256                final int maxLength) {
257            mOriginalText = originalText;
258            mRecommendedThreshold = recommendedThreshold;
259            mMaxLength = maxLength;
260            mSuggestions = CollectionUtils.newArrayList(maxLength + 1);
261            mScores = new int[mMaxLength];
262        }
263
264        synchronized public boolean addWord(char[] word, int[] spaceIndices, int wordOffset,
265                int wordLength, int score) {
266            final int positionIndex = Arrays.binarySearch(mScores, 0, mLength, score);
267            // binarySearch returns the index if the element exists, and -<insertion index> - 1
268            // if it doesn't. See documentation for binarySearch.
269            final int insertIndex = positionIndex >= 0 ? positionIndex : -positionIndex - 1;
270
271            if (insertIndex == 0 && mLength >= mMaxLength) {
272                // In the future, we may want to keep track of the best suggestion score even if
273                // we are asked for 0 suggestions. In this case, we can use the following
274                // (tested) code to keep it:
275                // If the maxLength is 0 (should never be less, but if it is, it's treated as 0)
276                // then we need to keep track of the best suggestion in mBestScore and
277                // mBestSuggestion. This is so that we know whether the best suggestion makes
278                // the score cutoff, since we need to know that to return a meaningful
279                // looksLikeTypo.
280                // if (0 >= mMaxLength) {
281                //     if (score > mBestScore) {
282                //         mBestScore = score;
283                //         mBestSuggestion = new String(word, wordOffset, wordLength);
284                //     }
285                // }
286                return true;
287            }
288            if (insertIndex >= mMaxLength) {
289                // We found a suggestion, but its score is too weak to be kept considering
290                // the suggestion limit.
291                return true;
292            }
293
294            final String wordString = new String(word, wordOffset, wordLength);
295            if (mLength < mMaxLength) {
296                final int copyLen = mLength - insertIndex;
297                ++mLength;
298                System.arraycopy(mScores, insertIndex, mScores, insertIndex + 1, copyLen);
299                mSuggestions.add(insertIndex, wordString);
300            } else {
301                System.arraycopy(mScores, 1, mScores, 0, insertIndex);
302                mSuggestions.add(insertIndex, wordString);
303                mSuggestions.remove(0);
304            }
305            mScores[insertIndex] = score;
306
307            return true;
308        }
309
310        public Result getResults(final int capitalizeType, final Locale locale) {
311            final String[] gatheredSuggestions;
312            final boolean hasRecommendedSuggestions;
313            if (0 == mLength) {
314                // TODO: the comment below describes what is intended, but in the practice
315                // mBestSuggestion is only ever set to null so it doesn't work. Fix this.
316                // Either we found no suggestions, or we found some BUT the max length was 0.
317                // If we found some mBestSuggestion will not be null. If it is null, then
318                // we found none, regardless of the max length.
319                if (null == mBestSuggestion) {
320                    gatheredSuggestions = null;
321                    hasRecommendedSuggestions = false;
322                } else {
323                    gatheredSuggestions = EMPTY_STRING_ARRAY;
324                    final float normalizedScore = BinaryDictionaryUtils.calcNormalizedScore(
325                            mOriginalText, mBestSuggestion, mBestScore);
326                    hasRecommendedSuggestions = (normalizedScore > mRecommendedThreshold);
327                }
328            } else {
329                if (DBG) {
330                    if (mLength != mSuggestions.size()) {
331                        Log.e(TAG, "Suggestion size is not the same as stored mLength");
332                    }
333                    for (int i = mLength - 1; i >= 0; --i) {
334                        Log.i(TAG, "" + mScores[i] + " " + mSuggestions.get(i));
335                    }
336                }
337                Collections.reverse(mSuggestions);
338                StringUtils.removeDupes(mSuggestions);
339                if (StringUtils.CAPITALIZE_ALL == capitalizeType) {
340                    for (int i = 0; i < mSuggestions.size(); ++i) {
341                        // get(i) returns a CharSequence which is actually a String so .toString()
342                        // should return the same object.
343                        mSuggestions.set(i, mSuggestions.get(i).toString().toUpperCase(locale));
344                    }
345                } else if (StringUtils.CAPITALIZE_FIRST == capitalizeType) {
346                    for (int i = 0; i < mSuggestions.size(); ++i) {
347                        // Likewise
348                        mSuggestions.set(i, StringUtils.capitalizeFirstCodePoint(
349                                mSuggestions.get(i).toString(), locale));
350                    }
351                }
352                // This returns a String[], while toArray() returns an Object[] which cannot be cast
353                // into a String[].
354                gatheredSuggestions = mSuggestions.toArray(EMPTY_STRING_ARRAY);
355
356                final int bestScore = mScores[mLength - 1];
357                final String bestSuggestion = mSuggestions.get(0);
358                final float normalizedScore =
359                        BinaryDictionaryUtils.calcNormalizedScore(
360                                mOriginalText, bestSuggestion.toString(), bestScore);
361                hasRecommendedSuggestions = (normalizedScore > mRecommendedThreshold);
362                if (DBG) {
363                    Log.i(TAG, "Best suggestion : " + bestSuggestion + ", score " + bestScore);
364                    Log.i(TAG, "Normalized score = " + normalizedScore
365                            + " (threshold " + mRecommendedThreshold
366                            + ") => hasRecommendedSuggestions = " + hasRecommendedSuggestions);
367                }
368            }
369            return new Result(gatheredSuggestions, hasRecommendedSuggestions);
370        }
371    }
372
373    @Override
374    public boolean onUnbind(final Intent intent) {
375        closeAllDictionaries();
376        return false;
377    }
378
379    private void closeAllDictionaries() {
380        final Map<String, DictionaryPool> oldPools = mDictionaryPools;
381        mDictionaryPools = CollectionUtils.newSynchronizedTreeMap();
382        final Map<String, UserBinaryDictionary> oldUserDictionaries = mUserDictionaries;
383        mUserDictionaries = CollectionUtils.newSynchronizedTreeMap();
384        new Thread("spellchecker_close_dicts") {
385            @Override
386            public void run() {
387                // Contacts dictionary can be closed multiple times here. If the dictionary is
388                // already closed, extra closings are no-ops, so it's safe.
389                for (DictionaryPool pool : oldPools.values()) {
390                    pool.close();
391                }
392                for (Dictionary dict : oldUserDictionaries.values()) {
393                    dict.close();
394                }
395                synchronized (mUseContactsLock) {
396                    if (null != mContactsDictionary) {
397                        // The synchronously loaded contacts dictionary should have been in one
398                        // or several pools, but it is shielded against multiple closing and it's
399                        // safe to call it several times.
400                        final ContactsBinaryDictionary dictToClose = mContactsDictionary;
401                        // TODO: revert to the concrete type when USE_BINARY_CONTACTS_DICTIONARY
402                        // is no longer needed
403                        mContactsDictionary = null;
404                        dictToClose.close();
405                    }
406                }
407            }
408        }.start();
409    }
410
411    public DictionaryPool getDictionaryPool(final String locale) {
412        DictionaryPool pool = mDictionaryPools.get(locale);
413        if (null == pool) {
414            final Locale localeObject = LocaleUtils.constructLocaleFromString(locale);
415            pool = new DictionaryPool(POOL_SIZE, this, localeObject);
416            mDictionaryPools.put(locale, pool);
417        }
418        return pool;
419    }
420
421    public DictAndKeyboard createDictAndKeyboard(final Locale locale) {
422        final int script = getScriptFromLocale(locale);
423        final String keyboardLayoutName = getKeyboardLayoutNameForScript(script);
424        final InputMethodSubtype subtype = AdditionalSubtypeUtils.createAdditionalSubtype(
425                locale.toString(), keyboardLayoutName, null);
426        final KeyboardLayoutSet keyboardLayoutSet = createKeyboardSetForSpellChecker(subtype);
427
428        final DictionaryCollection dictionaryCollection =
429                DictionaryFactory.createMainDictionaryFromManager(this, locale,
430                        true /* useFullEditDistance */);
431        final String localeStr = locale.toString();
432        UserBinaryDictionary userDictionary = mUserDictionaries.get(localeStr);
433        if (null == userDictionary) {
434            userDictionary = new SynchronouslyLoadedUserBinaryDictionary(this, locale, true);
435            mUserDictionaries.put(localeStr, userDictionary);
436        }
437        dictionaryCollection.addDictionary(userDictionary);
438        synchronized (mUseContactsLock) {
439            if (mUseContactsDictionary) {
440                if (null == mContactsDictionary) {
441                    // TODO: use the right locale. We can't do it right now because the
442                    // spell checker is reusing the contacts dictionary across sessions
443                    // without regard for their locale, so we need to fix that first.
444                    mContactsDictionary = new SynchronouslyLoadedContactsBinaryDictionary(this,
445                            Locale.getDefault());
446                }
447            }
448            dictionaryCollection.addDictionary(mContactsDictionary);
449            mDictionaryCollectionsList.add(
450                    new WeakReference<DictionaryCollection>(dictionaryCollection));
451        }
452        return new DictAndKeyboard(dictionaryCollection, keyboardLayoutSet);
453    }
454
455    private KeyboardLayoutSet createKeyboardSetForSpellChecker(final InputMethodSubtype subtype) {
456        final EditorInfo editorInfo = new EditorInfo();
457        editorInfo.inputType = InputType.TYPE_CLASS_TEXT;
458        final KeyboardLayoutSet.Builder builder = new KeyboardLayoutSet.Builder(this, editorInfo);
459        builder.setKeyboardGeometry(
460                SPELLCHECKER_DUMMY_KEYBOARD_WIDTH, SPELLCHECKER_DUMMY_KEYBOARD_HEIGHT);
461        builder.setSubtype(subtype);
462        builder.setIsSpellChecker(true /* isSpellChecker */);
463        builder.disableTouchPositionCorrectionData();
464        return builder.build();
465    }
466}
467