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