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.text.InputType; 24 import android.util.Log; 25 import android.view.inputmethod.EditorInfo; 26 import android.view.inputmethod.InputMethodSubtype; 27 import android.view.textservice.SuggestionsInfo; 28 29 import com.android.inputmethod.keyboard.KeyboardLayoutSet; 30 import com.android.inputmethod.latin.BinaryDictionary; 31 import com.android.inputmethod.latin.ContactsBinaryDictionary; 32 import com.android.inputmethod.latin.Dictionary; 33 import com.android.inputmethod.latin.DictionaryCollection; 34 import com.android.inputmethod.latin.DictionaryFactory; 35 import com.android.inputmethod.latin.R; 36 import com.android.inputmethod.latin.SynchronouslyLoadedContactsBinaryDictionary; 37 import com.android.inputmethod.latin.SynchronouslyLoadedUserBinaryDictionary; 38 import com.android.inputmethod.latin.UserBinaryDictionary; 39 import com.android.inputmethod.latin.utils.AdditionalSubtypeUtils; 40 import com.android.inputmethod.latin.utils.CollectionUtils; 41 import com.android.inputmethod.latin.utils.LocaleUtils; 42 import com.android.inputmethod.latin.utils.StringUtils; 43 44 import java.lang.ref.WeakReference; 45 import java.util.ArrayList; 46 import java.util.Arrays; 47 import java.util.Collections; 48 import java.util.HashSet; 49 import java.util.Iterator; 50 import java.util.Locale; 51 import java.util.Map; 52 import java.util.TreeMap; 53 54 /** 55 * Service for spell checking, using LatinIME's dictionaries and mechanisms. 56 */ 57 public 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