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