AndroidSpellCheckerService.java revision 86dee2295dccd9af3c58e946bc8f2b62736c0260
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.Context;
20import android.content.Intent;
21import android.content.SharedPreferences;
22import android.preference.PreferenceManager;
23import android.service.textservice.SpellCheckerService;
24import android.text.InputType;
25import android.util.Log;
26import android.util.LruCache;
27import android.view.inputmethod.EditorInfo;
28import android.view.inputmethod.InputMethodSubtype;
29import android.view.textservice.SuggestionsInfo;
30
31import com.android.inputmethod.keyboard.Keyboard;
32import com.android.inputmethod.keyboard.KeyboardId;
33import com.android.inputmethod.keyboard.KeyboardLayoutSet;
34import com.android.inputmethod.keyboard.ProximityInfo;
35import com.android.inputmethod.latin.ContactsBinaryDictionary;
36import com.android.inputmethod.latin.Dictionary;
37import com.android.inputmethod.latin.DictionaryCollection;
38import com.android.inputmethod.latin.DictionaryFacilitator;
39import com.android.inputmethod.latin.DictionaryFactory;
40import com.android.inputmethod.latin.PrevWordsInfo;
41import com.android.inputmethod.latin.R;
42import com.android.inputmethod.latin.SuggestedWords.SuggestedWordInfo;
43import com.android.inputmethod.latin.settings.SettingsValuesForSuggestion;
44import com.android.inputmethod.latin.UserBinaryDictionary;
45import com.android.inputmethod.latin.utils.AdditionalSubtypeUtils;
46import com.android.inputmethod.latin.utils.BinaryDictionaryUtils;
47import com.android.inputmethod.latin.utils.CollectionUtils;
48import com.android.inputmethod.latin.utils.LocaleUtils;
49import com.android.inputmethod.latin.utils.ScriptUtils;
50import com.android.inputmethod.latin.utils.StringUtils;
51import com.android.inputmethod.latin.utils.SuggestionResults;
52import com.android.inputmethod.latin.WordComposer;
53
54import java.lang.ref.WeakReference;
55import java.util.ArrayList;
56import java.util.Arrays;
57import java.util.Collections;
58import java.util.HashMap;
59import java.util.HashSet;
60import java.util.Iterator;
61import java.util.Locale;
62import java.util.Map;
63import java.util.TreeMap;
64import java.util.concurrent.ConcurrentHashMap;
65import java.util.concurrent.ConcurrentLinkedQueue;
66import java.util.concurrent.Semaphore;
67import java.util.concurrent.TimeUnit;
68
69/**
70 * Service for spell checking, using LatinIME's dictionaries and mechanisms.
71 */
72public final class AndroidSpellCheckerService extends SpellCheckerService
73        implements SharedPreferences.OnSharedPreferenceChangeListener {
74    private static final String TAG = AndroidSpellCheckerService.class.getSimpleName();
75    private static final boolean DBG = false;
76
77    public static final String PREF_USE_CONTACTS_KEY = "pref_spellcheck_use_contacts";
78
79    private static final int SPELLCHECKER_DUMMY_KEYBOARD_WIDTH = 480;
80    private static final int SPELLCHECKER_DUMMY_KEYBOARD_HEIGHT = 368;
81
82    private static final String DICTIONARY_NAME_PREFIX = "spellcheck_";
83    private static final int WAIT_FOR_LOADING_MAIN_DICT_IN_MILLISECONDS = 1000;
84    private static final int MAX_RETRY_COUNT_FOR_WAITING_FOR_LOADING_DICT = 5;
85
86    private static final String[] EMPTY_STRING_ARRAY = new String[0];
87
88    private final HashSet<Locale> mCachedLocales = new HashSet<>();
89
90    private final int MAX_NUM_OF_THREADS_READ_DICTIONARY = 2;
91    private final Semaphore mSemaphore = new Semaphore(MAX_NUM_OF_THREADS_READ_DICTIONARY,
92            true /* fair */);
93    // TODO: Make each spell checker session has its own session id.
94    private final ConcurrentLinkedQueue<Integer> mSessionIdPool = new ConcurrentLinkedQueue<>();
95
96    private static class DictionaryFacilitatorLruCache extends
97            LruCache<Locale, DictionaryFacilitator> {
98        private final HashSet<Locale> mCachedLocales;
99        public DictionaryFacilitatorLruCache(final HashSet<Locale> cachedLocales, int maxSize) {
100            super(maxSize);
101            mCachedLocales = cachedLocales;
102        }
103
104        @Override
105        protected void entryRemoved(boolean evicted, Locale key,
106                DictionaryFacilitator oldValue, DictionaryFacilitator newValue) {
107            if (oldValue != null && oldValue != newValue) {
108                oldValue.closeDictionaries();
109            }
110            if (key != null && newValue == null) {
111                // Remove locale from the cache when the dictionary facilitator for the locale is
112                // evicted and new facilitator is not set for the locale.
113                mCachedLocales.remove(key);
114                if (size() >= maxSize()) {
115                    Log.w(TAG, "DictionaryFacilitator for " + key.toString()
116                            + " has been evicted due to cache size limit."
117                            + " size: " + size() + ", maxSize: " + maxSize());
118                }
119            }
120        }
121    }
122
123    private static final int MAX_DICTIONARY_FACILITATOR_COUNT = 3;
124    private final LruCache<Locale, DictionaryFacilitator> mDictionaryFacilitatorCache =
125            new DictionaryFacilitatorLruCache(mCachedLocales, MAX_DICTIONARY_FACILITATOR_COUNT);
126    private final ConcurrentHashMap<Locale, Keyboard> mKeyboardCache = new ConcurrentHashMap<>();
127
128    // The threshold for a suggestion to be considered "recommended".
129    private float mRecommendedThreshold;
130    // Whether to use the contacts dictionary
131    private boolean mUseContactsDictionary;
132    // TODO: make a spell checker option to block offensive words or not
133    private final SettingsValuesForSuggestion mSettingsValuesForSuggestion =
134            new SettingsValuesForSuggestion(true /* blockPotentiallyOffensive */,
135                    true /* spaceAwareGestureEnabled */,
136                    null /* additionalFeaturesSettingValues */);
137    private final Object mDictionaryLock = new Object();
138
139    public static final String SINGLE_QUOTE = "\u0027";
140    public static final String APOSTROPHE = "\u2019";
141
142    public AndroidSpellCheckerService() {
143        super();
144        for (int i = 0; i < MAX_NUM_OF_THREADS_READ_DICTIONARY; i++) {
145            mSessionIdPool.add(i);
146        }
147    }
148
149    @Override public void onCreate() {
150        super.onCreate();
151        mRecommendedThreshold =
152                Float.parseFloat(getString(R.string.spellchecker_recommended_threshold_value));
153        final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
154        prefs.registerOnSharedPreferenceChangeListener(this);
155        onSharedPreferenceChanged(prefs, PREF_USE_CONTACTS_KEY);
156    }
157
158    private static String getKeyboardLayoutNameForScript(final int script) {
159        switch (script) {
160        case ScriptUtils.SCRIPT_LATIN:
161            return "qwerty";
162        case ScriptUtils.SCRIPT_CYRILLIC:
163            return "east_slavic";
164        case ScriptUtils.SCRIPT_GREEK:
165            return "greek";
166        default:
167            throw new RuntimeException("Wrong script supplied: " + script);
168        }
169    }
170
171    @Override
172    public void onSharedPreferenceChanged(final SharedPreferences prefs, final String key) {
173        if (!PREF_USE_CONTACTS_KEY.equals(key)) return;
174            final boolean useContactsDictionary = prefs.getBoolean(PREF_USE_CONTACTS_KEY, true);
175            if (useContactsDictionary != mUseContactsDictionary) {
176                mSemaphore.acquireUninterruptibly(MAX_NUM_OF_THREADS_READ_DICTIONARY);
177                try {
178                    mUseContactsDictionary = useContactsDictionary;
179                    for (final Locale locale : mCachedLocales) {
180                        final DictionaryFacilitator dictionaryFacilitator =
181                                mDictionaryFacilitatorCache.get(locale);
182                        resetDictionariesForLocale(this /* context  */,
183                                dictionaryFacilitator, locale, mUseContactsDictionary);
184                    }
185                } finally {
186                    mSemaphore.release(MAX_NUM_OF_THREADS_READ_DICTIONARY);
187                }
188            }
189    }
190
191    @Override
192    public Session createSession() {
193        // Should not refer to AndroidSpellCheckerSession directly considering
194        // that AndroidSpellCheckerSession may be overlaid.
195        return AndroidSpellCheckerSessionFactory.newInstance(this);
196    }
197
198    /**
199     * Returns an empty SuggestionsInfo with flags signaling the word is not in the dictionary.
200     * @param reportAsTypo whether this should include the flag LOOKS_LIKE_TYPO, for red underline.
201     * @return the empty SuggestionsInfo with the appropriate flags set.
202     */
203    public static SuggestionsInfo getNotInDictEmptySuggestions(final boolean reportAsTypo) {
204        return new SuggestionsInfo(reportAsTypo ? SuggestionsInfo.RESULT_ATTR_LOOKS_LIKE_TYPO : 0,
205                EMPTY_STRING_ARRAY);
206    }
207
208    /**
209     * Returns an empty suggestionInfo with flags signaling the word is in the dictionary.
210     * @return the empty SuggestionsInfo with the appropriate flags set.
211     */
212    public static SuggestionsInfo getInDictEmptySuggestions() {
213        return new SuggestionsInfo(SuggestionsInfo.RESULT_ATTR_IN_THE_DICTIONARY,
214                EMPTY_STRING_ARRAY);
215    }
216
217    public SuggestionsGatherer newSuggestionsGatherer(final String text, int maxLength) {
218        return new SuggestionsGatherer(text, mRecommendedThreshold, maxLength);
219    }
220
221    // TODO: remove this class and replace it by storage local to the session.
222    public static final class SuggestionsGatherer {
223        public static final class Result {
224            public final String[] mSuggestions;
225            public final boolean mHasRecommendedSuggestions;
226            public Result(final String[] gatheredSuggestions,
227                    final boolean hasRecommendedSuggestions) {
228                mSuggestions = gatheredSuggestions;
229                mHasRecommendedSuggestions = hasRecommendedSuggestions;
230            }
231        }
232
233        private final ArrayList<String> mSuggestions;
234        private final ArrayList<Integer> mScores;
235        private final String mOriginalText;
236        private final float mRecommendedThreshold;
237        private final int mMaxLength;
238
239        SuggestionsGatherer(final String originalText, final float recommendedThreshold,
240                final int maxLength) {
241            mOriginalText = originalText;
242            mRecommendedThreshold = recommendedThreshold;
243            mMaxLength = maxLength;
244            mSuggestions = new ArrayList<>();
245            mScores = new ArrayList<>();
246        }
247
248        public void addResults(final SuggestionResults suggestionResults) {
249            if (suggestionResults == null) {
250                return;
251            }
252            // suggestionResults is sorted.
253            for (final SuggestedWordInfo suggestedWordInfo : suggestionResults) {
254                mSuggestions.add(suggestedWordInfo.mWord);
255                mScores.add(suggestedWordInfo.mScore);
256            }
257        }
258
259        public Result getResults(final int capitalizeType, final Locale locale) {
260            final String[] gatheredSuggestions;
261            final boolean hasRecommendedSuggestions;
262            if (mSuggestions.isEmpty()) {
263                gatheredSuggestions = null;
264                hasRecommendedSuggestions = false;
265            } else {
266                if (DBG) {
267                    for (int i = 0; i < mSuggestions.size(); i++) {
268                        Log.i(TAG, "" + mScores.get(i) + " " + mSuggestions.get(i));
269                    }
270                }
271                StringUtils.removeDupes(mSuggestions);
272                if (StringUtils.CAPITALIZE_ALL == capitalizeType) {
273                    for (int i = 0; i < mSuggestions.size(); ++i) {
274                        // get(i) returns a CharSequence which is actually a String so .toString()
275                        // should return the same object.
276                        mSuggestions.set(i, mSuggestions.get(i).toString().toUpperCase(locale));
277                    }
278                } else if (StringUtils.CAPITALIZE_FIRST == capitalizeType) {
279                    for (int i = 0; i < mSuggestions.size(); ++i) {
280                        // Likewise
281                        mSuggestions.set(i, StringUtils.capitalizeFirstCodePoint(
282                                mSuggestions.get(i).toString(), locale));
283                    }
284                }
285                // This returns a String[], while toArray() returns an Object[] which cannot be cast
286                // into a String[].
287                gatheredSuggestions = mSuggestions.toArray(EMPTY_STRING_ARRAY);
288
289                final int bestScore = mScores.get(0);
290                final String bestSuggestion = mSuggestions.get(0);
291                final float normalizedScore =
292                        BinaryDictionaryUtils.calcNormalizedScore(
293                                mOriginalText, bestSuggestion.toString(), bestScore);
294                hasRecommendedSuggestions = (normalizedScore > mRecommendedThreshold);
295                if (DBG) {
296                    Log.i(TAG, "Best suggestion : " + bestSuggestion + ", score " + bestScore);
297                    Log.i(TAG, "Normalized score = " + normalizedScore
298                            + " (threshold " + mRecommendedThreshold
299                            + ") => hasRecommendedSuggestions = " + hasRecommendedSuggestions);
300                }
301            }
302            return new Result(gatheredSuggestions, hasRecommendedSuggestions);
303        }
304    }
305
306    public boolean isValidWord(final Locale locale, final String word) {
307        mSemaphore.acquireUninterruptibly();
308        try {
309            DictionaryFacilitator dictionaryFacilitatorForLocale =
310                    getDictionaryFacilitatorForLocaleLocked(locale);
311            return dictionaryFacilitatorForLocale.isValidWord(word, false /* igroreCase */);
312        } finally {
313            mSemaphore.release();
314        }
315    }
316
317    public SuggestionResults getSuggestionResults(final Locale locale, final WordComposer composer,
318            final PrevWordsInfo prevWordsInfo, final ProximityInfo proximityInfo) {
319        Integer sessionId = null;
320        mSemaphore.acquireUninterruptibly();
321        try {
322            sessionId = mSessionIdPool.poll();
323            DictionaryFacilitator dictionaryFacilitatorForLocale =
324                    getDictionaryFacilitatorForLocaleLocked(locale);
325            return dictionaryFacilitatorForLocale.getSuggestionResults(composer, prevWordsInfo,
326                    proximityInfo, mSettingsValuesForSuggestion, sessionId);
327        } finally {
328            if (sessionId != null) {
329                mSessionIdPool.add(sessionId);
330            }
331            mSemaphore.release();
332        }
333    }
334
335    public boolean hasMainDictionaryForLocale(final Locale locale) {
336        mSemaphore.acquireUninterruptibly();
337        try {
338            final DictionaryFacilitator dictionaryFacilitator =
339                    getDictionaryFacilitatorForLocaleLocked(locale);
340            return dictionaryFacilitator.hasInitializedMainDictionary();
341        } finally {
342            mSemaphore.release();
343        }
344    }
345
346    private DictionaryFacilitator getDictionaryFacilitatorForLocaleLocked(final Locale locale) {
347        DictionaryFacilitator dictionaryFacilitatorForLocale =
348                mDictionaryFacilitatorCache.get(locale);
349        if (dictionaryFacilitatorForLocale == null) {
350            dictionaryFacilitatorForLocale = new DictionaryFacilitator();
351            mDictionaryFacilitatorCache.put(locale, dictionaryFacilitatorForLocale);
352            mCachedLocales.add(locale);
353            resetDictionariesForLocale(this /* context */, dictionaryFacilitatorForLocale,
354                    locale, mUseContactsDictionary);
355        }
356        return dictionaryFacilitatorForLocale;
357    }
358
359    private static void resetDictionariesForLocale(final Context context,
360            final DictionaryFacilitator dictionaryFacilitator, final Locale locale,
361            final boolean useContactsDictionary) {
362        dictionaryFacilitator.resetDictionariesWithDictNamePrefix(context, locale,
363                useContactsDictionary, false /* usePersonalizedDicts */,
364                false /* forceReloadMainDictionary */, null /* listener */,
365                DICTIONARY_NAME_PREFIX);
366        for (int i = 0; i < MAX_RETRY_COUNT_FOR_WAITING_FOR_LOADING_DICT; i++) {
367            try {
368                dictionaryFacilitator.waitForLoadingMainDictionary(
369                        WAIT_FOR_LOADING_MAIN_DICT_IN_MILLISECONDS, TimeUnit.MILLISECONDS);
370                return;
371            } catch (final InterruptedException e) {
372                Log.i(TAG, "Interrupted during waiting for loading main dictionary.", e);
373                if (i < MAX_RETRY_COUNT_FOR_WAITING_FOR_LOADING_DICT - 1) {
374                    Log.i(TAG, "Retry", e);
375                } else {
376                    Log.w(TAG, "Give up retrying. Retried "
377                            + MAX_RETRY_COUNT_FOR_WAITING_FOR_LOADING_DICT + " times.", e);
378                }
379            }
380        }
381    }
382
383    @Override
384    public boolean onUnbind(final Intent intent) {
385        mSemaphore.acquireUninterruptibly(MAX_NUM_OF_THREADS_READ_DICTIONARY);
386        try {
387            mDictionaryFacilitatorCache.evictAll();
388            mCachedLocales.clear();
389        } finally {
390            mSemaphore.release(MAX_NUM_OF_THREADS_READ_DICTIONARY);
391        }
392        mKeyboardCache.clear();
393        return false;
394    }
395
396    public Keyboard getKeyboardForLocale(final Locale locale) {
397        Keyboard keyboard = mKeyboardCache.get(locale);
398        if (keyboard == null) {
399            keyboard = createKeyboardForLocale(locale);
400            if (keyboard != null) {
401                mKeyboardCache.put(locale, keyboard);
402            }
403        }
404        return keyboard;
405    }
406
407    private Keyboard createKeyboardForLocale(final Locale locale) {
408        final int script = ScriptUtils.getScriptFromSpellCheckerLocale(locale);
409        final String keyboardLayoutName = getKeyboardLayoutNameForScript(script);
410        final InputMethodSubtype subtype = AdditionalSubtypeUtils.createDummyAdditionalSubtype(
411                locale.toString(), keyboardLayoutName);
412        final KeyboardLayoutSet keyboardLayoutSet = createKeyboardSetForSpellChecker(subtype);
413        return keyboardLayoutSet.getKeyboard(KeyboardId.ELEMENT_ALPHABET);
414    }
415
416    private KeyboardLayoutSet createKeyboardSetForSpellChecker(final InputMethodSubtype subtype) {
417        final EditorInfo editorInfo = new EditorInfo();
418        editorInfo.inputType = InputType.TYPE_CLASS_TEXT;
419        final KeyboardLayoutSet.Builder builder = new KeyboardLayoutSet.Builder(this, editorInfo);
420        builder.setKeyboardGeometry(
421                SPELLCHECKER_DUMMY_KEYBOARD_WIDTH, SPELLCHECKER_DUMMY_KEYBOARD_HEIGHT);
422        builder.setSubtype(subtype);
423        builder.setIsSpellChecker(true /* isSpellChecker */);
424        builder.disableTouchPositionCorrectionData();
425        return builder.build();
426    }
427}
428