AndroidSpellCheckerService.java revision 9e76304d6004c43c3149bc2df460af2a00b18a4f
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 int[] mScores;
235        private final String mOriginalText;
236        private final float mRecommendedThreshold;
237        private final int mMaxLength;
238        private int mLength = 0;
239
240        SuggestionsGatherer(final String originalText, final float recommendedThreshold,
241                final int maxLength) {
242            mOriginalText = originalText;
243            mRecommendedThreshold = recommendedThreshold;
244            mMaxLength = maxLength;
245            mSuggestions = new ArrayList<>(maxLength + 1);
246            mScores = new int[mMaxLength];
247        }
248
249        synchronized public boolean addWord(char[] word, int[] spaceIndices, int wordOffset,
250                int wordLength, int score) {
251            final int positionIndex = Arrays.binarySearch(mScores, 0, mLength, score);
252            // binarySearch returns the index if the element exists, and -<insertion index> - 1
253            // if it doesn't. See documentation for binarySearch.
254            final int insertIndex = positionIndex >= 0 ? positionIndex : -positionIndex - 1;
255
256            // Weak <- insertIndex == 0, ..., insertIndex == mLength -> Strong
257            if (insertIndex == 0 && mLength >= mMaxLength) {
258                return true;
259            }
260
261            final String wordString = new String(word, wordOffset, wordLength);
262            if (mLength < mMaxLength) {
263                final int copyLen = mLength - insertIndex;
264                ++mLength;
265                System.arraycopy(mScores, insertIndex, mScores, insertIndex + 1, copyLen);
266                mSuggestions.add(insertIndex, wordString);
267                mScores[insertIndex] = score;
268            } else {
269                System.arraycopy(mScores, 1, mScores, 0, insertIndex - 1);
270                mSuggestions.add(insertIndex, wordString);
271                mSuggestions.remove(0);
272                mScores[insertIndex - 1] = score;
273            }
274
275            return true;
276        }
277
278        public Result getResults(final int capitalizeType, final Locale locale) {
279            final String[] gatheredSuggestions;
280            final boolean hasRecommendedSuggestions;
281            if (0 == mLength) {
282                gatheredSuggestions = null;
283                hasRecommendedSuggestions = false;
284            } else {
285                if (DBG) {
286                    if (mLength != mSuggestions.size()) {
287                        Log.e(TAG, "Suggestion size is not the same as stored mLength");
288                    }
289                    for (int i = mLength - 1; i >= 0; --i) {
290                        Log.i(TAG, "" + mScores[i] + " " + mSuggestions.get(i));
291                    }
292                }
293                Collections.reverse(mSuggestions);
294                StringUtils.removeDupes(mSuggestions);
295                if (StringUtils.CAPITALIZE_ALL == capitalizeType) {
296                    for (int i = 0; i < mSuggestions.size(); ++i) {
297                        // get(i) returns a CharSequence which is actually a String so .toString()
298                        // should return the same object.
299                        mSuggestions.set(i, mSuggestions.get(i).toString().toUpperCase(locale));
300                    }
301                } else if (StringUtils.CAPITALIZE_FIRST == capitalizeType) {
302                    for (int i = 0; i < mSuggestions.size(); ++i) {
303                        // Likewise
304                        mSuggestions.set(i, StringUtils.capitalizeFirstCodePoint(
305                                mSuggestions.get(i).toString(), locale));
306                    }
307                }
308                // This returns a String[], while toArray() returns an Object[] which cannot be cast
309                // into a String[].
310                gatheredSuggestions = mSuggestions.toArray(EMPTY_STRING_ARRAY);
311
312                final int bestScore = mScores[mLength - 1];
313                final String bestSuggestion = mSuggestions.get(0);
314                final float normalizedScore =
315                        BinaryDictionaryUtils.calcNormalizedScore(
316                                mOriginalText, bestSuggestion.toString(), bestScore);
317                hasRecommendedSuggestions = (normalizedScore > mRecommendedThreshold);
318                if (DBG) {
319                    Log.i(TAG, "Best suggestion : " + bestSuggestion + ", score " + bestScore);
320                    Log.i(TAG, "Normalized score = " + normalizedScore
321                            + " (threshold " + mRecommendedThreshold
322                            + ") => hasRecommendedSuggestions = " + hasRecommendedSuggestions);
323                }
324            }
325            return new Result(gatheredSuggestions, hasRecommendedSuggestions);
326        }
327    }
328
329    public boolean isValidWord(final Locale locale, final String word) {
330        mSemaphore.acquireUninterruptibly();
331        try {
332            DictionaryFacilitator dictionaryFacilitatorForLocale =
333                    getDictionaryFacilitatorForLocaleLocked(locale);
334            return dictionaryFacilitatorForLocale.isValidWord(word, false /* igroreCase */);
335        } finally {
336            mSemaphore.release();
337        }
338    }
339
340    public SuggestionResults getSuggestionResults(final Locale locale, final WordComposer composer,
341            final PrevWordsInfo prevWordsInfo, final ProximityInfo proximityInfo) {
342        Integer sessionId = null;
343        mSemaphore.acquireUninterruptibly();
344        try {
345            sessionId = mSessionIdPool.poll();
346            DictionaryFacilitator dictionaryFacilitatorForLocale =
347                    getDictionaryFacilitatorForLocaleLocked(locale);
348            return dictionaryFacilitatorForLocale.getSuggestionResults(composer, prevWordsInfo,
349                    proximityInfo, mSettingsValuesForSuggestion, sessionId);
350        } finally {
351            if (sessionId != null) {
352                mSessionIdPool.add(sessionId);
353            }
354            mSemaphore.release();
355        }
356    }
357
358    public boolean hasMainDictionaryForLocale(final Locale locale) {
359        mSemaphore.acquireUninterruptibly();
360        try {
361            final DictionaryFacilitator dictionaryFacilitator =
362                    getDictionaryFacilitatorForLocaleLocked(locale);
363            return dictionaryFacilitator.hasInitializedMainDictionary();
364        } finally {
365            mSemaphore.release();
366        }
367    }
368
369    private DictionaryFacilitator getDictionaryFacilitatorForLocaleLocked(final Locale locale) {
370        DictionaryFacilitator dictionaryFacilitatorForLocale =
371                mDictionaryFacilitatorCache.get(locale);
372        if (dictionaryFacilitatorForLocale == null) {
373            dictionaryFacilitatorForLocale = new DictionaryFacilitator();
374            mDictionaryFacilitatorCache.put(locale, dictionaryFacilitatorForLocale);
375            mCachedLocales.add(locale);
376            resetDictionariesForLocale(this /* context */, dictionaryFacilitatorForLocale,
377                    locale, mUseContactsDictionary);
378        }
379        return dictionaryFacilitatorForLocale;
380    }
381
382    private static void resetDictionariesForLocale(final Context context,
383            final DictionaryFacilitator dictionaryFacilitator, final Locale locale,
384            final boolean useContactsDictionary) {
385        dictionaryFacilitator.resetDictionariesWithDictNamePrefix(context, locale,
386                useContactsDictionary, false /* usePersonalizedDicts */,
387                false /* forceReloadMainDictionary */, null /* listener */,
388                DICTIONARY_NAME_PREFIX);
389        for (int i = 0; i < MAX_RETRY_COUNT_FOR_WAITING_FOR_LOADING_DICT; i++) {
390            try {
391                dictionaryFacilitator.waitForLoadingMainDictionary(
392                        WAIT_FOR_LOADING_MAIN_DICT_IN_MILLISECONDS, TimeUnit.MILLISECONDS);
393                return;
394            } catch (final InterruptedException e) {
395                Log.i(TAG, "Interrupted during waiting for loading main dictionary.", e);
396                if (i < MAX_RETRY_COUNT_FOR_WAITING_FOR_LOADING_DICT - 1) {
397                    Log.i(TAG, "Retry", e);
398                } else {
399                    Log.w(TAG, "Give up retrying. Retried "
400                            + MAX_RETRY_COUNT_FOR_WAITING_FOR_LOADING_DICT + " times.", e);
401                }
402            }
403        }
404    }
405
406    @Override
407    public boolean onUnbind(final Intent intent) {
408        mSemaphore.acquireUninterruptibly(MAX_NUM_OF_THREADS_READ_DICTIONARY);
409        try {
410            mDictionaryFacilitatorCache.evictAll();
411            mCachedLocales.clear();
412        } finally {
413            mSemaphore.release(MAX_NUM_OF_THREADS_READ_DICTIONARY);
414        }
415        mKeyboardCache.clear();
416        return false;
417    }
418
419    public Keyboard getKeyboardForLocale(final Locale locale) {
420        Keyboard keyboard = mKeyboardCache.get(locale);
421        if (keyboard == null) {
422            keyboard = createKeyboardForLocale(locale);
423            if (keyboard != null) {
424                mKeyboardCache.put(locale, keyboard);
425            }
426        }
427        return keyboard;
428    }
429
430    private Keyboard createKeyboardForLocale(final Locale locale) {
431        final int script = ScriptUtils.getScriptFromSpellCheckerLocale(locale);
432        final String keyboardLayoutName = getKeyboardLayoutNameForScript(script);
433        final InputMethodSubtype subtype = AdditionalSubtypeUtils.createDummyAdditionalSubtype(
434                locale.toString(), keyboardLayoutName);
435        final KeyboardLayoutSet keyboardLayoutSet = createKeyboardSetForSpellChecker(subtype);
436        return keyboardLayoutSet.getKeyboard(KeyboardId.ELEMENT_ALPHABET);
437    }
438
439    private KeyboardLayoutSet createKeyboardSetForSpellChecker(final InputMethodSubtype subtype) {
440        final EditorInfo editorInfo = new EditorInfo();
441        editorInfo.inputType = InputType.TYPE_CLASS_TEXT;
442        final KeyboardLayoutSet.Builder builder = new KeyboardLayoutSet.Builder(this, editorInfo);
443        builder.setKeyboardGeometry(
444                SPELLCHECKER_DUMMY_KEYBOARD_WIDTH, SPELLCHECKER_DUMMY_KEYBOARD_HEIGHT);
445        builder.setSubtype(subtype);
446        builder.setIsSpellChecker(true /* isSpellChecker */);
447        builder.disableTouchPositionCorrectionData();
448        return builder.build();
449    }
450}
451