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