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 
17 package com.android.inputmethod.latin.spellcheck;
18 
19 import android.content.Context;
20 import android.content.Intent;
21 import android.content.SharedPreferences;
22 import android.preference.PreferenceManager;
23 import android.service.textservice.SpellCheckerService;
24 import android.text.InputType;
25 import android.util.Log;
26 import android.util.LruCache;
27 import android.view.inputmethod.EditorInfo;
28 import android.view.inputmethod.InputMethodSubtype;
29 import android.view.textservice.SuggestionsInfo;
30 
31 import com.android.inputmethod.keyboard.Keyboard;
32 import com.android.inputmethod.keyboard.KeyboardId;
33 import com.android.inputmethod.keyboard.KeyboardLayoutSet;
34 import com.android.inputmethod.keyboard.ProximityInfo;
35 import com.android.inputmethod.latin.ContactsBinaryDictionary;
36 import com.android.inputmethod.latin.Dictionary;
37 import com.android.inputmethod.latin.DictionaryCollection;
38 import com.android.inputmethod.latin.DictionaryFacilitator;
39 import com.android.inputmethod.latin.DictionaryFactory;
40 import com.android.inputmethod.latin.PrevWordsInfo;
41 import com.android.inputmethod.latin.R;
42 import com.android.inputmethod.latin.SuggestedWords.SuggestedWordInfo;
43 import com.android.inputmethod.latin.settings.SettingsValuesForSuggestion;
44 import com.android.inputmethod.latin.UserBinaryDictionary;
45 import com.android.inputmethod.latin.utils.AdditionalSubtypeUtils;
46 import com.android.inputmethod.latin.utils.BinaryDictionaryUtils;
47 import com.android.inputmethod.latin.utils.CollectionUtils;
48 import com.android.inputmethod.latin.utils.LocaleUtils;
49 import com.android.inputmethod.latin.utils.ScriptUtils;
50 import com.android.inputmethod.latin.utils.StringUtils;
51 import com.android.inputmethod.latin.utils.SuggestionResults;
52 import com.android.inputmethod.latin.WordComposer;
53 
54 import java.lang.ref.WeakReference;
55 import java.util.ArrayList;
56 import java.util.Arrays;
57 import java.util.Collections;
58 import java.util.HashMap;
59 import java.util.HashSet;
60 import java.util.Iterator;
61 import java.util.Locale;
62 import java.util.Map;
63 import java.util.TreeMap;
64 import java.util.concurrent.ConcurrentHashMap;
65 import java.util.concurrent.ConcurrentLinkedQueue;
66 import java.util.concurrent.Semaphore;
67 import java.util.concurrent.TimeUnit;
68 
69 /**
70  * Service for spell checking, using LatinIME's dictionaries and mechanisms.
71  */
72 public 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;
DictionaryFacilitatorLruCache(final HashSet<Locale> cachedLocales, int maxSize)99         public DictionaryFacilitatorLruCache(final HashSet<Locale> cachedLocales, int maxSize) {
100             super(maxSize);
101             mCachedLocales = cachedLocales;
102         }
103 
104         @Override
entryRemoved(boolean evicted, Locale key, DictionaryFacilitator oldValue, DictionaryFacilitator newValue)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 
AndroidSpellCheckerService()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 
onCreate()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 
getRecommendedThreshold()158     public float getRecommendedThreshold() {
159         return mRecommendedThreshold;
160     }
161 
getKeyboardLayoutNameForScript(final int script)162     private static String getKeyboardLayoutNameForScript(final int script) {
163         switch (script) {
164         case ScriptUtils.SCRIPT_LATIN:
165             return "qwerty";
166         case ScriptUtils.SCRIPT_CYRILLIC:
167             return "east_slavic";
168         case ScriptUtils.SCRIPT_GREEK:
169             return "greek";
170         default:
171             throw new RuntimeException("Wrong script supplied: " + script);
172         }
173     }
174 
175     @Override
onSharedPreferenceChanged(final SharedPreferences prefs, final String key)176     public void onSharedPreferenceChanged(final SharedPreferences prefs, final String key) {
177         if (!PREF_USE_CONTACTS_KEY.equals(key)) return;
178             final boolean useContactsDictionary = prefs.getBoolean(PREF_USE_CONTACTS_KEY, true);
179             if (useContactsDictionary != mUseContactsDictionary) {
180                 mSemaphore.acquireUninterruptibly(MAX_NUM_OF_THREADS_READ_DICTIONARY);
181                 try {
182                     mUseContactsDictionary = useContactsDictionary;
183                     for (final Locale locale : mCachedLocales) {
184                         final DictionaryFacilitator dictionaryFacilitator =
185                                 mDictionaryFacilitatorCache.get(locale);
186                         resetDictionariesForLocale(this /* context  */,
187                                 dictionaryFacilitator, locale, mUseContactsDictionary);
188                     }
189                 } finally {
190                     mSemaphore.release(MAX_NUM_OF_THREADS_READ_DICTIONARY);
191                 }
192             }
193     }
194 
195     @Override
createSession()196     public Session createSession() {
197         // Should not refer to AndroidSpellCheckerSession directly considering
198         // that AndroidSpellCheckerSession may be overlaid.
199         return AndroidSpellCheckerSessionFactory.newInstance(this);
200     }
201 
202     /**
203      * Returns an empty SuggestionsInfo with flags signaling the word is not in the dictionary.
204      * @param reportAsTypo whether this should include the flag LOOKS_LIKE_TYPO, for red underline.
205      * @return the empty SuggestionsInfo with the appropriate flags set.
206      */
getNotInDictEmptySuggestions(final boolean reportAsTypo)207     public static SuggestionsInfo getNotInDictEmptySuggestions(final boolean reportAsTypo) {
208         return new SuggestionsInfo(reportAsTypo ? SuggestionsInfo.RESULT_ATTR_LOOKS_LIKE_TYPO : 0,
209                 EMPTY_STRING_ARRAY);
210     }
211 
212     /**
213      * Returns an empty suggestionInfo with flags signaling the word is in the dictionary.
214      * @return the empty SuggestionsInfo with the appropriate flags set.
215      */
getInDictEmptySuggestions()216     public static SuggestionsInfo getInDictEmptySuggestions() {
217         return new SuggestionsInfo(SuggestionsInfo.RESULT_ATTR_IN_THE_DICTIONARY,
218                 EMPTY_STRING_ARRAY);
219     }
220 
isValidWord(final Locale locale, final String word)221     public boolean isValidWord(final Locale locale, final String word) {
222         mSemaphore.acquireUninterruptibly();
223         try {
224             DictionaryFacilitator dictionaryFacilitatorForLocale =
225                     getDictionaryFacilitatorForLocaleLocked(locale);
226             return dictionaryFacilitatorForLocale.isValidWord(word, false /* igroreCase */);
227         } finally {
228             mSemaphore.release();
229         }
230     }
231 
getSuggestionResults(final Locale locale, final WordComposer composer, final PrevWordsInfo prevWordsInfo, final ProximityInfo proximityInfo)232     public SuggestionResults getSuggestionResults(final Locale locale, final WordComposer composer,
233             final PrevWordsInfo prevWordsInfo, final ProximityInfo proximityInfo) {
234         Integer sessionId = null;
235         mSemaphore.acquireUninterruptibly();
236         try {
237             sessionId = mSessionIdPool.poll();
238             DictionaryFacilitator dictionaryFacilitatorForLocale =
239                     getDictionaryFacilitatorForLocaleLocked(locale);
240             return dictionaryFacilitatorForLocale.getSuggestionResults(composer, prevWordsInfo,
241                     proximityInfo, mSettingsValuesForSuggestion, sessionId);
242         } finally {
243             if (sessionId != null) {
244                 mSessionIdPool.add(sessionId);
245             }
246             mSemaphore.release();
247         }
248     }
249 
hasMainDictionaryForLocale(final Locale locale)250     public boolean hasMainDictionaryForLocale(final Locale locale) {
251         mSemaphore.acquireUninterruptibly();
252         try {
253             final DictionaryFacilitator dictionaryFacilitator =
254                     getDictionaryFacilitatorForLocaleLocked(locale);
255             return dictionaryFacilitator.hasInitializedMainDictionary();
256         } finally {
257             mSemaphore.release();
258         }
259     }
260 
getDictionaryFacilitatorForLocaleLocked(final Locale locale)261     private DictionaryFacilitator getDictionaryFacilitatorForLocaleLocked(final Locale locale) {
262         DictionaryFacilitator dictionaryFacilitatorForLocale =
263                 mDictionaryFacilitatorCache.get(locale);
264         if (dictionaryFacilitatorForLocale == null) {
265             dictionaryFacilitatorForLocale = new DictionaryFacilitator();
266             mDictionaryFacilitatorCache.put(locale, dictionaryFacilitatorForLocale);
267             mCachedLocales.add(locale);
268             resetDictionariesForLocale(this /* context */, dictionaryFacilitatorForLocale,
269                     locale, mUseContactsDictionary);
270         }
271         return dictionaryFacilitatorForLocale;
272     }
273 
resetDictionariesForLocale(final Context context, final DictionaryFacilitator dictionaryFacilitator, final Locale locale, final boolean useContactsDictionary)274     private static void resetDictionariesForLocale(final Context context,
275             final DictionaryFacilitator dictionaryFacilitator, final Locale locale,
276             final boolean useContactsDictionary) {
277         dictionaryFacilitator.resetDictionariesWithDictNamePrefix(context, locale,
278                 useContactsDictionary, false /* usePersonalizedDicts */,
279                 false /* forceReloadMainDictionary */, null /* listener */,
280                 DICTIONARY_NAME_PREFIX);
281         for (int i = 0; i < MAX_RETRY_COUNT_FOR_WAITING_FOR_LOADING_DICT; i++) {
282             try {
283                 dictionaryFacilitator.waitForLoadingMainDictionary(
284                         WAIT_FOR_LOADING_MAIN_DICT_IN_MILLISECONDS, TimeUnit.MILLISECONDS);
285                 return;
286             } catch (final InterruptedException e) {
287                 Log.i(TAG, "Interrupted during waiting for loading main dictionary.", e);
288                 if (i < MAX_RETRY_COUNT_FOR_WAITING_FOR_LOADING_DICT - 1) {
289                     Log.i(TAG, "Retry", e);
290                 } else {
291                     Log.w(TAG, "Give up retrying. Retried "
292                             + MAX_RETRY_COUNT_FOR_WAITING_FOR_LOADING_DICT + " times.", e);
293                 }
294             }
295         }
296     }
297 
298     @Override
onUnbind(final Intent intent)299     public boolean onUnbind(final Intent intent) {
300         mSemaphore.acquireUninterruptibly(MAX_NUM_OF_THREADS_READ_DICTIONARY);
301         try {
302             mDictionaryFacilitatorCache.evictAll();
303             mCachedLocales.clear();
304         } finally {
305             mSemaphore.release(MAX_NUM_OF_THREADS_READ_DICTIONARY);
306         }
307         mKeyboardCache.clear();
308         return false;
309     }
310 
getKeyboardForLocale(final Locale locale)311     public Keyboard getKeyboardForLocale(final Locale locale) {
312         Keyboard keyboard = mKeyboardCache.get(locale);
313         if (keyboard == null) {
314             keyboard = createKeyboardForLocale(locale);
315             if (keyboard != null) {
316                 mKeyboardCache.put(locale, keyboard);
317             }
318         }
319         return keyboard;
320     }
321 
createKeyboardForLocale(final Locale locale)322     private Keyboard createKeyboardForLocale(final Locale locale) {
323         final int script = ScriptUtils.getScriptFromSpellCheckerLocale(locale);
324         final String keyboardLayoutName = getKeyboardLayoutNameForScript(script);
325         final InputMethodSubtype subtype = AdditionalSubtypeUtils.createDummyAdditionalSubtype(
326                 locale.toString(), keyboardLayoutName);
327         final KeyboardLayoutSet keyboardLayoutSet = createKeyboardSetForSpellChecker(subtype);
328         return keyboardLayoutSet.getKeyboard(KeyboardId.ELEMENT_ALPHABET);
329     }
330 
createKeyboardSetForSpellChecker(final InputMethodSubtype subtype)331     private KeyboardLayoutSet createKeyboardSetForSpellChecker(final InputMethodSubtype subtype) {
332         final EditorInfo editorInfo = new EditorInfo();
333         editorInfo.inputType = InputType.TYPE_CLASS_TEXT;
334         final KeyboardLayoutSet.Builder builder = new KeyboardLayoutSet.Builder(this, editorInfo);
335         builder.setKeyboardGeometry(
336                 SPELLCHECKER_DUMMY_KEYBOARD_WIDTH, SPELLCHECKER_DUMMY_KEYBOARD_HEIGHT);
337         builder.setSubtype(subtype);
338         builder.setIsSpellChecker(true /* isSpellChecker */);
339         builder.disableTouchPositionCorrectionData();
340         return builder.build();
341     }
342 }
343