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