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