diff options
Diffstat (limited to 'java/src/com/android/inputmethod/latin/spellcheck')
6 files changed, 560 insertions, 203 deletions
diff --git a/java/src/com/android/inputmethod/latin/spellcheck/AndroidSpellCheckerService.java b/java/src/com/android/inputmethod/latin/spellcheck/AndroidSpellCheckerService.java index 90398deb2..00f69f158 100644 --- a/java/src/com/android/inputmethod/latin/spellcheck/AndroidSpellCheckerService.java +++ b/java/src/com/android/inputmethod/latin/spellcheck/AndroidSpellCheckerService.java @@ -16,55 +16,37 @@ package com.android.inputmethod.latin.spellcheck; -import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.preference.PreferenceManager; import android.service.textservice.SpellCheckerService; import android.text.InputType; -import android.util.Log; -import android.util.LruCache; import android.view.inputmethod.EditorInfo; import android.view.inputmethod.InputMethodSubtype; import android.view.textservice.SuggestionsInfo; +import android.util.Log; import com.android.inputmethod.keyboard.Keyboard; import com.android.inputmethod.keyboard.KeyboardId; import com.android.inputmethod.keyboard.KeyboardLayoutSet; -import com.android.inputmethod.keyboard.ProximityInfo; -import com.android.inputmethod.latin.ContactsBinaryDictionary; -import com.android.inputmethod.latin.Dictionary; -import com.android.inputmethod.latin.DictionaryCollection; import com.android.inputmethod.latin.DictionaryFacilitator; -import com.android.inputmethod.latin.DictionaryFactory; -import com.android.inputmethod.latin.PrevWordsInfo; +import com.android.inputmethod.latin.DictionaryFacilitatorLruCache; +import com.android.inputmethod.latin.NgramContext; import com.android.inputmethod.latin.R; -import com.android.inputmethod.latin.SuggestedWords.SuggestedWordInfo; +import com.android.inputmethod.latin.RichInputMethodSubtype; +import com.android.inputmethod.latin.SuggestedWords; +import com.android.inputmethod.latin.common.ComposedData; import com.android.inputmethod.latin.settings.SettingsValuesForSuggestion; -import com.android.inputmethod.latin.UserBinaryDictionary; import com.android.inputmethod.latin.utils.AdditionalSubtypeUtils; -import com.android.inputmethod.latin.utils.BinaryDictionaryUtils; -import com.android.inputmethod.latin.utils.CollectionUtils; -import com.android.inputmethod.latin.utils.LocaleUtils; import com.android.inputmethod.latin.utils.ScriptUtils; -import com.android.inputmethod.latin.utils.StringUtils; import com.android.inputmethod.latin.utils.SuggestionResults; -import com.android.inputmethod.latin.WordComposer; -import java.lang.ref.WeakReference; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Iterator; import java.util.Locale; -import java.util.Map; -import java.util.TreeMap; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.Semaphore; -import java.util.concurrent.TimeUnit; + +import javax.annotation.Nonnull; /** * Service for spell checking, using LatinIME's dictionaries and mechanisms. @@ -72,72 +54,36 @@ import java.util.concurrent.TimeUnit; public final class AndroidSpellCheckerService extends SpellCheckerService implements SharedPreferences.OnSharedPreferenceChangeListener { private static final String TAG = AndroidSpellCheckerService.class.getSimpleName(); - private static final boolean DBG = false; + private static final boolean DEBUG = false; public static final String PREF_USE_CONTACTS_KEY = "pref_spellcheck_use_contacts"; private static final int SPELLCHECKER_DUMMY_KEYBOARD_WIDTH = 480; - private static final int SPELLCHECKER_DUMMY_KEYBOARD_HEIGHT = 368; + private static final int SPELLCHECKER_DUMMY_KEYBOARD_HEIGHT = 301; private static final String DICTIONARY_NAME_PREFIX = "spellcheck_"; - private static final int WAIT_FOR_LOADING_MAIN_DICT_IN_MILLISECONDS = 1000; - private static final int MAX_RETRY_COUNT_FOR_WAITING_FOR_LOADING_DICT = 5; private static final String[] EMPTY_STRING_ARRAY = new String[0]; - private final HashSet<Locale> mCachedLocales = new HashSet<>(); - private final int MAX_NUM_OF_THREADS_READ_DICTIONARY = 2; private final Semaphore mSemaphore = new Semaphore(MAX_NUM_OF_THREADS_READ_DICTIONARY, true /* fair */); // TODO: Make each spell checker session has its own session id. private final ConcurrentLinkedQueue<Integer> mSessionIdPool = new ConcurrentLinkedQueue<>(); - private static class DictionaryFacilitatorLruCache extends - LruCache<Locale, DictionaryFacilitator> { - private final HashSet<Locale> mCachedLocales; - public DictionaryFacilitatorLruCache(final HashSet<Locale> cachedLocales, int maxSize) { - super(maxSize); - mCachedLocales = cachedLocales; - } - - @Override - protected void entryRemoved(boolean evicted, Locale key, - DictionaryFacilitator oldValue, DictionaryFacilitator newValue) { - if (oldValue != null && oldValue != newValue) { - oldValue.closeDictionaries(); - } - if (key != null && newValue == null) { - // Remove locale from the cache when the dictionary facilitator for the locale is - // evicted and new facilitator is not set for the locale. - mCachedLocales.remove(key); - if (size() >= maxSize()) { - Log.w(TAG, "DictionaryFacilitator for " + key.toString() - + " has been evicted due to cache size limit." - + " size: " + size() + ", maxSize: " + maxSize()); - } - } - } - } - - private static final int MAX_DICTIONARY_FACILITATOR_COUNT = 3; - private final LruCache<Locale, DictionaryFacilitator> mDictionaryFacilitatorCache = - new DictionaryFacilitatorLruCache(mCachedLocales, MAX_DICTIONARY_FACILITATOR_COUNT); + private final DictionaryFacilitatorLruCache mDictionaryFacilitatorCache = + new DictionaryFacilitatorLruCache(this /* context */, DICTIONARY_NAME_PREFIX); private final ConcurrentHashMap<Locale, Keyboard> mKeyboardCache = new ConcurrentHashMap<>(); // The threshold for a suggestion to be considered "recommended". private float mRecommendedThreshold; - // Whether to use the contacts dictionary - private boolean mUseContactsDictionary; // TODO: make a spell checker option to block offensive words or not private final SettingsValuesForSuggestion mSettingsValuesForSuggestion = - new SettingsValuesForSuggestion(true /* blockPotentiallyOffensive */, - true /* spaceAwareGestureEnabled */, - null /* additionalFeaturesSettingValues */); - private final Object mDictionaryLock = new Object(); + new SettingsValuesForSuggestion(true /* blockPotentiallyOffensive */); public static final String SINGLE_QUOTE = "\u0027"; public static final String APOSTROPHE = "\u2019"; + private UserDictionaryLookup mUserDictionaryLookup; public AndroidSpellCheckerService() { super(); @@ -146,13 +92,33 @@ public final class AndroidSpellCheckerService extends SpellCheckerService } } - @Override public void onCreate() { + @Override + public void onCreate() { super.onCreate(); mRecommendedThreshold = Float.parseFloat(getString(R.string.spellchecker_recommended_threshold_value)); final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); prefs.registerOnSharedPreferenceChangeListener(this); onSharedPreferenceChanged(prefs, PREF_USE_CONTACTS_KEY); + // Create a UserDictionaryLookup. It needs to be close()d and set to null in onDestroy. + if (mUserDictionaryLookup == null) { + if (DEBUG) { + Log.d(TAG, "Creating mUserDictionaryLookup in onCreate"); + } + mUserDictionaryLookup = new UserDictionaryLookup(this); + } else if (DEBUG) { + Log.d(TAG, "mUserDictionaryLookup already created before onCreate"); + } + } + + @Override + public void onDestroy() { + if (DEBUG) { + Log.d(TAG, "Closing and dereferencing mUserDictionaryLookup in onDestroy"); + } + mUserDictionaryLookup.close(); + mUserDictionaryLookup = null; + super.onDestroy(); } public float getRecommendedThreshold() { @@ -175,21 +141,8 @@ public final class AndroidSpellCheckerService extends SpellCheckerService @Override public void onSharedPreferenceChanged(final SharedPreferences prefs, final String key) { if (!PREF_USE_CONTACTS_KEY.equals(key)) return; - final boolean useContactsDictionary = prefs.getBoolean(PREF_USE_CONTACTS_KEY, true); - if (useContactsDictionary != mUseContactsDictionary) { - mSemaphore.acquireUninterruptibly(MAX_NUM_OF_THREADS_READ_DICTIONARY); - try { - mUseContactsDictionary = useContactsDictionary; - for (final Locale locale : mCachedLocales) { - final DictionaryFacilitator dictionaryFacilitator = - mDictionaryFacilitatorCache.get(locale); - resetDictionariesForLocale(this /* context */, - dictionaryFacilitator, locale, mUseContactsDictionary); - } - } finally { - mSemaphore.release(MAX_NUM_OF_THREADS_READ_DICTIONARY); - } - } + final boolean useContactsDictionary = prefs.getBoolean(PREF_USE_CONTACTS_KEY, true); + mDictionaryFacilitatorCache.setUseContactsDictionary(useContactsDictionary); } @Override @@ -221,24 +174,36 @@ public final class AndroidSpellCheckerService extends SpellCheckerService public boolean isValidWord(final Locale locale, final String word) { mSemaphore.acquireUninterruptibly(); try { + if (mUserDictionaryLookup.isValidWord(word, locale)) { + if (DEBUG) { + Log.d(TAG, "mUserDictionaryLookup.isValidWord(" + word + ")=true"); + } + return true; + } else { + if (DEBUG) { + Log.d(TAG, "mUserDictionaryLookup.isValidWord(" + word + ")=false"); + } + } DictionaryFacilitator dictionaryFacilitatorForLocale = - getDictionaryFacilitatorForLocaleLocked(locale); - return dictionaryFacilitatorForLocale.isValidWord(word, false /* igroreCase */); + mDictionaryFacilitatorCache.get(locale); + return dictionaryFacilitatorForLocale.isValidSpellingWord(word); } finally { mSemaphore.release(); } } - public SuggestionResults getSuggestionResults(final Locale locale, final WordComposer composer, - final PrevWordsInfo prevWordsInfo, final ProximityInfo proximityInfo) { + public SuggestionResults getSuggestionResults(final Locale locale, + final ComposedData composedData, final NgramContext ngramContext, + @Nonnull final Keyboard keyboard) { Integer sessionId = null; mSemaphore.acquireUninterruptibly(); try { sessionId = mSessionIdPool.poll(); DictionaryFacilitator dictionaryFacilitatorForLocale = - getDictionaryFacilitatorForLocaleLocked(locale); - return dictionaryFacilitatorForLocale.getSuggestionResults(composer, prevWordsInfo, - proximityInfo, mSettingsValuesForSuggestion, sessionId); + mDictionaryFacilitatorCache.get(locale); + return dictionaryFacilitatorForLocale.getSuggestionResults(composedData, ngramContext, + keyboard, mSettingsValuesForSuggestion, + sessionId, SuggestedWords.INPUT_STYLE_TYPING); } finally { if (sessionId != null) { mSessionIdPool.add(sessionId); @@ -251,56 +216,18 @@ public final class AndroidSpellCheckerService extends SpellCheckerService mSemaphore.acquireUninterruptibly(); try { final DictionaryFacilitator dictionaryFacilitator = - getDictionaryFacilitatorForLocaleLocked(locale); - return dictionaryFacilitator.hasInitializedMainDictionary(); + mDictionaryFacilitatorCache.get(locale); + return dictionaryFacilitator.hasAtLeastOneInitializedMainDictionary(); } finally { mSemaphore.release(); } } - private DictionaryFacilitator getDictionaryFacilitatorForLocaleLocked(final Locale locale) { - DictionaryFacilitator dictionaryFacilitatorForLocale = - mDictionaryFacilitatorCache.get(locale); - if (dictionaryFacilitatorForLocale == null) { - dictionaryFacilitatorForLocale = new DictionaryFacilitator(); - mDictionaryFacilitatorCache.put(locale, dictionaryFacilitatorForLocale); - mCachedLocales.add(locale); - resetDictionariesForLocale(this /* context */, dictionaryFacilitatorForLocale, - locale, mUseContactsDictionary); - } - return dictionaryFacilitatorForLocale; - } - - private static void resetDictionariesForLocale(final Context context, - final DictionaryFacilitator dictionaryFacilitator, final Locale locale, - final boolean useContactsDictionary) { - dictionaryFacilitator.resetDictionariesWithDictNamePrefix(context, locale, - useContactsDictionary, false /* usePersonalizedDicts */, - false /* forceReloadMainDictionary */, null /* listener */, - DICTIONARY_NAME_PREFIX); - for (int i = 0; i < MAX_RETRY_COUNT_FOR_WAITING_FOR_LOADING_DICT; i++) { - try { - dictionaryFacilitator.waitForLoadingMainDictionary( - WAIT_FOR_LOADING_MAIN_DICT_IN_MILLISECONDS, TimeUnit.MILLISECONDS); - return; - } catch (final InterruptedException e) { - Log.i(TAG, "Interrupted during waiting for loading main dictionary.", e); - if (i < MAX_RETRY_COUNT_FOR_WAITING_FOR_LOADING_DICT - 1) { - Log.i(TAG, "Retry", e); - } else { - Log.w(TAG, "Give up retrying. Retried " - + MAX_RETRY_COUNT_FOR_WAITING_FOR_LOADING_DICT + " times.", e); - } - } - } - } - @Override public boolean onUnbind(final Intent intent) { mSemaphore.acquireUninterruptibly(MAX_NUM_OF_THREADS_READ_DICTIONARY); try { - mDictionaryFacilitatorCache.evictAll(); - mCachedLocales.clear(); + mDictionaryFacilitatorCache.closeDictionaries(); } finally { mSemaphore.release(MAX_NUM_OF_THREADS_READ_DICTIONARY); } @@ -334,7 +261,7 @@ public final class AndroidSpellCheckerService extends SpellCheckerService final KeyboardLayoutSet.Builder builder = new KeyboardLayoutSet.Builder(this, editorInfo); builder.setKeyboardGeometry( SPELLCHECKER_DUMMY_KEYBOARD_WIDTH, SPELLCHECKER_DUMMY_KEYBOARD_HEIGHT); - builder.setSubtype(subtype); + builder.setSubtype(RichInputMethodSubtype.getRichInputMethodSubtype(subtype)); builder.setIsSpellChecker(true /* isSpellChecker */); builder.disableTouchPositionCorrectionData(); return builder.build(); diff --git a/java/src/com/android/inputmethod/latin/spellcheck/AndroidSpellCheckerSession.java b/java/src/com/android/inputmethod/latin/spellcheck/AndroidSpellCheckerSession.java index 34e01197a..2c690aea7 100644 --- a/java/src/com/android/inputmethod/latin/spellcheck/AndroidSpellCheckerSession.java +++ b/java/src/com/android/inputmethod/latin/spellcheck/AndroidSpellCheckerSession.java @@ -16,8 +16,10 @@ package com.android.inputmethod.latin.spellcheck; +import android.annotation.TargetApi; import android.content.res.Resources; import android.os.Binder; +import android.os.Build; import android.text.TextUtils; import android.util.Log; import android.view.textservice.SentenceSuggestionsInfo; @@ -25,8 +27,8 @@ import android.view.textservice.SuggestionsInfo; import android.view.textservice.TextInfo; import com.android.inputmethod.compat.TextInfoCompatUtils; -import com.android.inputmethod.latin.PrevWordsInfo; -import com.android.inputmethod.latin.utils.StringUtils; +import com.android.inputmethod.latin.NgramContext; +import com.android.inputmethod.latin.utils.SpannableStringUtils; import java.util.ArrayList; import java.util.Locale; @@ -42,6 +44,7 @@ public final class AndroidSpellCheckerSession extends AndroidWordLevelSpellCheck mResources = service.getResources(); } + @TargetApi(Build.VERSION_CODES.JELLY_BEAN) private SentenceSuggestionsInfo fixWronglyInvalidatedWordWithSingleQuote(TextInfo ti, SentenceSuggestionsInfo ssi) { final CharSequence typedText = TextInfoCompatUtils.getCharSequenceOrString(ti); @@ -62,15 +65,16 @@ public final class AndroidSpellCheckerSession extends AndroidWordLevelSpellCheck final int offset = ssi.getOffsetAt(i); final int length = ssi.getLengthAt(i); final CharSequence subText = typedText.subSequence(offset, offset + length); - final PrevWordsInfo prevWordsInfo = - new PrevWordsInfo(new PrevWordsInfo.WordInfo(currentWord)); + final NgramContext ngramContext = + new NgramContext(new NgramContext.WordInfo(currentWord)); currentWord = subText; if (!subText.toString().contains(AndroidSpellCheckerService.SINGLE_QUOTE)) { continue; } - final CharSequence[] splitTexts = StringUtils.split(subText, + // Split preserving spans. + final CharSequence[] splitTexts = SpannableStringUtils.split(subText, AndroidSpellCheckerService.SINGLE_QUOTE, - true /* preserveTrailingEmptySegments */ ); + true /* preserveTrailingEmptySegments */); if (splitTexts == null || splitTexts.length <= 1) { continue; } @@ -80,7 +84,7 @@ public final class AndroidSpellCheckerSession extends AndroidWordLevelSpellCheck if (TextUtils.isEmpty(splitText)) { continue; } - if (mSuggestionsCache.getSuggestionsFromCache(splitText.toString(), prevWordsInfo) + if (mSuggestionsCache.getSuggestionsFromCache(splitText.toString(), ngramContext) == null) { continue; } @@ -149,7 +153,7 @@ public final class AndroidSpellCheckerSession extends AndroidWordLevelSpellCheck * @param textInfos an array of the text metadata * @param suggestionsLimit the maximum number of suggestions to be returned * @return an array of {@link SentenceSuggestionsInfo} returned by - * {@link SpellCheckerService.Session#onGetSuggestions(TextInfo, int)} + * {@link android.service.textservice.SpellCheckerService.Session#onGetSuggestions(TextInfo, int)} */ private SentenceSuggestionsInfo[] splitAndSuggest(TextInfo[] textInfos, int suggestionsLimit) { if (textInfos == null || textInfos.length == 0) { @@ -208,10 +212,10 @@ public final class AndroidSpellCheckerSession extends AndroidWordLevelSpellCheck } else { prevWord = null; } - final PrevWordsInfo prevWordsInfo = - new PrevWordsInfo(new PrevWordsInfo.WordInfo(prevWord)); + final NgramContext ngramContext = + new NgramContext(new NgramContext.WordInfo(prevWord)); final TextInfo textInfo = textInfos[i]; - retval[i] = onGetSuggestionsInternal(textInfo, prevWordsInfo, suggestionsLimit); + retval[i] = onGetSuggestionsInternal(textInfo, ngramContext, suggestionsLimit); retval[i].setCookieAndSequence(textInfo.getCookie(), textInfo.getSequence()); } return retval; diff --git a/java/src/com/android/inputmethod/latin/spellcheck/AndroidWordLevelSpellCheckerSession.java b/java/src/com/android/inputmethod/latin/spellcheck/AndroidWordLevelSpellCheckerSession.java index d668672aa..da5c71738 100644 --- a/java/src/com/android/inputmethod/latin/spellcheck/AndroidWordLevelSpellCheckerSession.java +++ b/java/src/com/android/inputmethod/latin/spellcheck/AndroidWordLevelSpellCheckerSession.java @@ -29,19 +29,19 @@ import android.view.textservice.TextInfo; import com.android.inputmethod.compat.SuggestionsInfoCompatUtils; import com.android.inputmethod.keyboard.Keyboard; -import com.android.inputmethod.keyboard.ProximityInfo; -import com.android.inputmethod.latin.Constants; -import com.android.inputmethod.latin.PrevWordsInfo; +import com.android.inputmethod.latin.NgramContext; import com.android.inputmethod.latin.SuggestedWords.SuggestedWordInfo; import com.android.inputmethod.latin.WordComposer; +import com.android.inputmethod.latin.common.Constants; +import com.android.inputmethod.latin.common.LocaleUtils; +import com.android.inputmethod.latin.common.StringUtils; import com.android.inputmethod.latin.utils.BinaryDictionaryUtils; -import com.android.inputmethod.latin.utils.CoordinateUtils; -import com.android.inputmethod.latin.utils.LocaleUtils; import com.android.inputmethod.latin.utils.ScriptUtils; -import com.android.inputmethod.latin.utils.StringUtils; +import com.android.inputmethod.latin.utils.StatsUtils; import com.android.inputmethod.latin.utils.SuggestionResults; import java.util.ArrayList; +import java.util.List; import java.util.Locale; public abstract class AndroidWordLevelSpellCheckerSession extends Session { @@ -73,27 +73,25 @@ public abstract class AndroidWordLevelSpellCheckerSession extends Session { private final LruCache<String, SuggestionsParams> mUnigramSuggestionsInfoCache = new LruCache<>(MAX_CACHE_SIZE); - // TODO: Support n-gram input - private static String generateKey(final String query, final PrevWordsInfo prevWordsInfo) { - if (TextUtils.isEmpty(query) || !prevWordsInfo.isValid()) { + private static String generateKey(final String query, final NgramContext ngramContext) { + if (TextUtils.isEmpty(query) || !ngramContext.isValid()) { return query; } - return query + CHAR_DELIMITER + prevWordsInfo; + return query + CHAR_DELIMITER + ngramContext; } public SuggestionsParams getSuggestionsFromCache(String query, - final PrevWordsInfo prevWordsInfo) { - return mUnigramSuggestionsInfoCache.get(generateKey(query, prevWordsInfo)); + final NgramContext ngramContext) { + return mUnigramSuggestionsInfoCache.get(generateKey(query, ngramContext)); } - public void putSuggestionsToCache( - final String query, final PrevWordsInfo prevWordsInfo, + public void putSuggestionsToCache(final String query, final NgramContext ngramContext, final String[] suggestions, final int flags) { if (suggestions == null || TextUtils.isEmpty(query)) { return; } mUnigramSuggestionsInfoCache.put( - generateKey(query, prevWordsInfo), new SuggestionsParams(suggestions, flags)); + generateKey(query, ngramContext), new SuggestionsParams(suggestions, flags)); } public void clearCache() { @@ -117,7 +115,8 @@ public abstract class AndroidWordLevelSpellCheckerSession extends Session { @Override public void onCreate() { final String localeString = getLocale(); - mLocale = LocaleUtils.constructLocaleFromString(localeString); + mLocale = (null == localeString) ? null + : LocaleUtils.constructLocaleFromString(localeString); mScript = ScriptUtils.getScriptFromSpellCheckerLocale(mLocale); } @@ -223,12 +222,11 @@ public abstract class AndroidWordLevelSpellCheckerSession extends Session { } protected SuggestionsInfo onGetSuggestionsInternal( - final TextInfo textInfo, final PrevWordsInfo prevWordsInfo, - final int suggestionsLimit) { + final TextInfo textInfo, final NgramContext ngramContext, final int suggestionsLimit) { try { final String inText = textInfo.getText(); final SuggestionsParams cachedSuggestionsParams = - mSuggestionsCache.getSuggestionsFromCache(inText, prevWordsInfo); + mSuggestionsCache.getSuggestionsFromCache(inText, ngramContext); if (cachedSuggestionsParams != null) { if (DBG) { Log.d(TAG, "Cache hit: " + inText + ", " + cachedSuggestionsParams.mFlags); @@ -262,31 +260,28 @@ public abstract class AndroidWordLevelSpellCheckerSession extends Session { final String text = inText.replaceAll( AndroidSpellCheckerService.APOSTROPHE, AndroidSpellCheckerService.SINGLE_QUOTE); final int capitalizeType = StringUtils.getCapitalizationType(text); - boolean isInDict = true; if (!mService.hasMainDictionaryForLocale(mLocale)) { return AndroidSpellCheckerService.getNotInDictEmptySuggestions( false /* reportAsTypo */); } final Keyboard keyboard = mService.getKeyboardForLocale(mLocale); + if (null == keyboard) { + Log.d(TAG, "No keyboard for locale: " + mLocale); + // If there is no keyboard for this locale, don't do any spell-checking. + return AndroidSpellCheckerService.getNotInDictEmptySuggestions( + false /* reportAsTypo */); + } final WordComposer composer = new WordComposer(); final int[] codePoints = StringUtils.toCodePointArray(text); final int[] coordinates; - final ProximityInfo proximityInfo; - if (null == keyboard) { - coordinates = CoordinateUtils.newCoordinateArray(codePoints.length, - Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE); - proximityInfo = null; - } else { - coordinates = keyboard.getCoordinates(codePoints); - proximityInfo = keyboard.getProximityInfo(); - } + coordinates = keyboard.getCoordinates(codePoints); composer.setComposingWord(codePoints, coordinates); // TODO: Don't gather suggestions if the limit is <= 0 unless necessary final SuggestionResults suggestionResults = mService.getSuggestionResults( - mLocale, composer, prevWordsInfo, proximityInfo); + mLocale, composer.getComposedDataSnapshot(), ngramContext, keyboard); final Result result = getResult(capitalizeType, mLocale, suggestionsLimit, mService.getRecommendedThreshold(), text, suggestionResults); - isInDict = isInDictForAnyCapitalization(text, capitalizeType); + final boolean isInDict = isInDictForAnyCapitalization(text, capitalizeType); if (DBG) { Log.i(TAG, "Spell checking results for " + text + " with suggestion limit " + suggestionsLimit); @@ -299,6 +294,15 @@ public abstract class AndroidWordLevelSpellCheckerSession extends Session { } } } + // Handle word not in dictionary. + // This is called only once per unique word, so entering multiple + // instances of the same word does not result in more than one call + // to this method. + // Also, upon changing the orientation of the device, this is called + // again for every unique invalid word in the text box. + if (!isInDict) { + StatsUtils.onInvalidWordIdentification(text); + } final int flags = (isInDict ? SuggestionsInfo.RESULT_ATTR_IN_THE_DICTIONARY @@ -308,26 +312,24 @@ public abstract class AndroidWordLevelSpellCheckerSession extends Session { .getValueOf_RESULT_ATTR_HAS_RECOMMENDED_SUGGESTIONS() : 0); final SuggestionsInfo retval = new SuggestionsInfo(flags, result.mSuggestions); - mSuggestionsCache.putSuggestionsToCache(text, prevWordsInfo, result.mSuggestions, + mSuggestionsCache.putSuggestionsToCache(text, ngramContext, result.mSuggestions, flags); return retval; } catch (RuntimeException e) { // Don't kill the keyboard if there is a bug in the spell checker if (DBG) { throw e; - } else { - Log.e(TAG, "Exception while spellcheking", e); - return AndroidSpellCheckerService.getNotInDictEmptySuggestions( - false /* reportAsTypo */); } + Log.e(TAG, "Exception while spellcheking", e); + return AndroidSpellCheckerService.getNotInDictEmptySuggestions( + false /* reportAsTypo */); } } private static final class Result { public final String[] mSuggestions; public final boolean mHasRecommendedSuggestions; - public Result(final String[] gatheredSuggestions, - final boolean hasRecommendedSuggestions) { + public Result(final String[] gatheredSuggestions, final boolean hasRecommendedSuggestions) { mSuggestions = gatheredSuggestions; mHasRecommendedSuggestions = hasRecommendedSuggestions; } @@ -361,14 +363,15 @@ public abstract class AndroidWordLevelSpellCheckerSession extends Session { StringUtils.removeDupes(suggestions); // This returns a String[], while toArray() returns an Object[] which cannot be cast // into a String[]. + final List<String> gatheredSuggestionsList = + suggestions.subList(0, Math.min(suggestions.size(), suggestionsLimit)); final String[] gatheredSuggestions = - suggestions.subList(0, Math.min(suggestions.size(), suggestionsLimit)) - .toArray(EMPTY_STRING_ARRAY); + gatheredSuggestionsList.toArray(new String[gatheredSuggestionsList.size()]); final int bestScore = suggestionResults.first().mScore; final String bestSuggestion = suggestions.get(0); final float normalizedScore = BinaryDictionaryUtils.calcNormalizedScore( - originalText, bestSuggestion.toString(), bestScore); + originalText, bestSuggestion, bestScore); final boolean hasRecommendedSuggestions = (normalizedScore > recommendedThreshold); if (DBG) { Log.i(TAG, "Best suggestion : " + bestSuggestion + ", score " + bestScore); @@ -387,8 +390,7 @@ public abstract class AndroidWordLevelSpellCheckerSession extends Session { * That's what the following method does. */ @Override - public SuggestionsInfo onGetSuggestions(final TextInfo textInfo, - final int suggestionsLimit) { + public SuggestionsInfo onGetSuggestions(final TextInfo textInfo, final int suggestionsLimit) { long ident = Binder.clearCallingIdentity(); try { return onGetSuggestionsInternal(textInfo, suggestionsLimit); diff --git a/java/src/com/android/inputmethod/latin/spellcheck/SentenceLevelAdapter.java b/java/src/com/android/inputmethod/latin/spellcheck/SentenceLevelAdapter.java index 51c4b1ee8..10c458c7d 100644 --- a/java/src/com/android/inputmethod/latin/spellcheck/SentenceLevelAdapter.java +++ b/java/src/com/android/inputmethod/latin/spellcheck/SentenceLevelAdapter.java @@ -16,13 +16,15 @@ package com.android.inputmethod.latin.spellcheck; +import android.annotation.TargetApi; import android.content.res.Resources; +import android.os.Build; import android.view.textservice.SentenceSuggestionsInfo; import android.view.textservice.SuggestionsInfo; import android.view.textservice.TextInfo; import com.android.inputmethod.compat.TextInfoCompatUtils; -import com.android.inputmethod.latin.Constants; +import com.android.inputmethod.latin.common.Constants; import com.android.inputmethod.latin.settings.SpacingAndPunctuations; import com.android.inputmethod.latin.utils.RunInLocale; @@ -76,19 +78,19 @@ public class SentenceLevelAdapter { private static class WordIterator { private final SpacingAndPunctuations mSpacingAndPunctuations; public WordIterator(final Resources res, final Locale locale) { - final RunInLocale<SpacingAndPunctuations> job - = new RunInLocale<SpacingAndPunctuations>() { + final RunInLocale<SpacingAndPunctuations> job = + new RunInLocale<SpacingAndPunctuations>() { @Override - protected SpacingAndPunctuations job(final Resources res) { - return new SpacingAndPunctuations(res); + protected SpacingAndPunctuations job(final Resources r) { + return new SpacingAndPunctuations(r); } }; mSpacingAndPunctuations = job.runInLocale(res, locale); } - public int getEndOfWord(final CharSequence sequence, int index) { + public int getEndOfWord(final CharSequence sequence, final int fromIndex) { final int length = sequence.length(); - index = index < 0 ? 0 : Character.offsetByCodePoints(sequence, index, 1); + int index = fromIndex < 0 ? 0 : Character.offsetByCodePoints(sequence, fromIndex, 1); while (index < length) { final int codePoint = Character.codePointAt(sequence, index); if (mSpacingAndPunctuations.isWordSeparator(codePoint)) { @@ -111,12 +113,12 @@ public class SentenceLevelAdapter { return index; } - public int getBeginningOfNextWord(final CharSequence sequence, int index) { + public int getBeginningOfNextWord(final CharSequence sequence, final int fromIndex) { final int length = sequence.length(); - if (index >= length) { + if (fromIndex >= length) { return -1; } - index = index < 0 ? 0 : Character.offsetByCodePoints(sequence, index, 1); + int index = fromIndex < 0 ? 0 : Character.offsetByCodePoints(sequence, fromIndex, 1); while (index < length) { final int codePoint = Character.codePointAt(sequence, index); if (!mSpacingAndPunctuations.isWordSeparator(codePoint)) { @@ -140,14 +142,13 @@ public class SentenceLevelAdapter { final int cookie = originalTextInfo.getCookie(); final int start = -1; final int end = originalText.length(); - final ArrayList<SentenceWordItem> wordItems = new ArrayList<SentenceWordItem>(); + final ArrayList<SentenceWordItem> wordItems = new ArrayList<>(); int wordStart = wordIterator.getBeginningOfNextWord(originalText, start); int wordEnd = wordIterator.getEndOfWord(originalText, wordStart); while (wordStart <= end && wordEnd != -1 && wordStart != -1) { if (wordEnd >= start && wordEnd > wordStart) { - CharSequence subSequence = originalText.subSequence(wordStart, wordEnd).toString(); - final TextInfo ti = TextInfoCompatUtils.newInstance(subSequence, 0, - subSequence.length(), cookie, subSequence.hashCode()); + final TextInfo ti = TextInfoCompatUtils.newInstance(originalText, wordStart, + wordEnd, cookie, originalText.subSequence(wordStart, wordEnd).hashCode()); wordItems.add(new SentenceWordItem(ti, wordStart, wordEnd)); } wordStart = wordIterator.getBeginningOfNextWord(originalText, wordEnd); @@ -159,6 +160,7 @@ public class SentenceLevelAdapter { return new SentenceTextInfoParams(originalTextInfo, wordItems); } + @TargetApi(Build.VERSION_CODES.JELLY_BEAN) public static SentenceSuggestionsInfo reconstructSuggestions( SentenceTextInfoParams originalTextInfoParams, SuggestionsInfo[] results) { if (results == null || results.length == 0) { diff --git a/java/src/com/android/inputmethod/latin/spellcheck/SpellCheckerSettingsActivity.java b/java/src/com/android/inputmethod/latin/spellcheck/SpellCheckerSettingsActivity.java index df9a76119..294666b8b 100644 --- a/java/src/com/android/inputmethod/latin/spellcheck/SpellCheckerSettingsActivity.java +++ b/java/src/com/android/inputmethod/latin/spellcheck/SpellCheckerSettingsActivity.java @@ -18,7 +18,9 @@ package com.android.inputmethod.latin.spellcheck; import com.android.inputmethod.latin.utils.FragmentUtils; +import android.annotation.TargetApi; import android.content.Intent; +import android.os.Build; import android.os.Bundle; import android.preference.PreferenceActivity; @@ -41,8 +43,8 @@ public final class SpellCheckerSettingsActivity extends PreferenceActivity { return modIntent; } - // TODO: Uncomment the override annotation once we start using SDK version 19. - // @Override + @TargetApi(Build.VERSION_CODES.KITKAT) + @Override public boolean isValidFragment(String fragmentName) { return FragmentUtils.isValidFragment(fragmentName); } diff --git a/java/src/com/android/inputmethod/latin/spellcheck/UserDictionaryLookup.java b/java/src/com/android/inputmethod/latin/spellcheck/UserDictionaryLookup.java new file mode 100644 index 000000000..f2491f478 --- /dev/null +++ b/java/src/com/android/inputmethod/latin/spellcheck/UserDictionaryLookup.java @@ -0,0 +1,420 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.inputmethod.latin.spellcheck; + +import android.content.ContentResolver; +import android.content.Context; +import android.database.ContentObserver; +import android.database.Cursor; +import android.net.Uri; +import android.provider.UserDictionary; +import android.util.Log; + +import com.android.inputmethod.annotations.UsedForTesting; +import com.android.inputmethod.latin.common.LocaleUtils; +import com.android.inputmethod.latin.utils.ExecutorUtils; + +import java.io.Closeable; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Locale; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +/** + * UserDictionaryLookup provides the ability to lookup into the system-wide "Personal dictionary". + * + * Note, that the initial dictionary loading happens asynchronously so it is possible (hopefully + * rarely) that isValidWord is called before the initial load has started. + * + * The caller should explicitly call close() when the object is no longer needed, in order to + * release any resources and references to this object. A service should create this object in + * onCreate and close() it in onDestroy. + */ +public class UserDictionaryLookup implements Closeable { + private static final String TAG = UserDictionaryLookup.class.getSimpleName(); + + /** + * This guards the execution of any Log.d() logging, so that if false, they are not even + */ + private static final boolean DEBUG = false; + + /** + * To avoid loading too many dictionary entries in memory, we cap them at this number. If + * that number is exceeded, the lowest-frequency items will be dropped. Note, there is no + * explicit cap on the number of locales in every entry. + */ + private static final int MAX_NUM_ENTRIES = 1000; + + /** + * The delay (in milliseconds) to impose on reloads. Previously scheduled reloads will be + * cancelled if a new reload is scheduled before the delay expires. Thus, only the last + * reload in the series of frequent reloads will execute. + * + * Note, this value should be low enough to allow the "Add to dictionary" feature in the + * TextView correction (red underline) drop-down menu to work properly in the following case: + * + * 1. User types OOV (out-of-vocabulary) word. + * 2. The OOV is red-underlined. + * 3. User selects "Add to dictionary". The red underline disappears while the OOV is + * in a composing span. + * 4. The user taps space. The red underline should NOT reappear. If this value is very + * high and the user performs the space tap fast enough, the red underline may reappear. + */ + @UsedForTesting + static final int RELOAD_DELAY_MS = 200; + + private final ContentResolver mResolver; + + /** + * Runnable that calls loadUserDictionary(). + */ + private class UserDictionaryLoader implements Runnable { + @Override + public void run() { + if (DEBUG) { + Log.d(TAG, "Executing (re)load"); + } + loadUserDictionary(); + } + } + private final UserDictionaryLoader mLoader = new UserDictionaryLoader(); + + /** + * Content observer for UserDictionary changes. It has the following properties: + * 1. It spawns off a UserDictionary reload in another thread, after some delay. + * 2. It cancels previously scheduled reloads, and only executes the latest. + * 3. It may be called multiple times quickly in succession (and is in fact called so + * when UserDictionary is edited through its settings UI, when sometimes multiple + * notifications are sent for the edited entry, but also for the entire UserDictionary). + */ + private class UserDictionaryContentObserver extends ContentObserver { + public UserDictionaryContentObserver() { + super(null); + } + + @Override + public boolean deliverSelfNotifications() { + return true; + } + + // Support pre-API16 platforms. + @Override + public void onChange(boolean selfChange) { + onChange(selfChange, null); + } + + @Override + public void onChange(boolean selfChange, Uri uri) { + if (DEBUG) { + Log.d(TAG, "Received content observer onChange notification for URI: " + uri); + } + // Cancel (but don't interrupt) any pending reloads (except the initial load). + if (mReloadFuture != null && !mReloadFuture.isCancelled() && + !mReloadFuture.isDone()) { + // Note, that if already cancelled or done, this will do nothing. + boolean isCancelled = mReloadFuture.cancel(false); + if (DEBUG) { + if (isCancelled) { + Log.d(TAG, "Successfully canceled previous reload request"); + } else { + Log.d(TAG, "Unable to cancel previous reload request"); + } + } + } + + if (DEBUG) { + Log.d(TAG, "Scheduling reload in " + RELOAD_DELAY_MS + " ms"); + } + + // Schedule a new reload after RELOAD_DELAY_MS. + mReloadFuture = ExecutorUtils.getBackgroundExecutor(ExecutorUtils.SPELLING) + .schedule(mLoader, RELOAD_DELAY_MS, TimeUnit.MILLISECONDS); + } + } + private final ContentObserver mObserver = new UserDictionaryContentObserver(); + + /** + * Indicates that a load is in progress, so no need for another. + */ + private AtomicBoolean mIsLoading = new AtomicBoolean(false); + + /** + * Indicates that this lookup object has been close()d. + */ + private AtomicBoolean mIsClosed = new AtomicBoolean(false); + + /** + * We store a map from a dictionary word to the set of locales it belongs + * in. We then iterate over the set of locales to find a match using + * LocaleUtils. + */ + private volatile HashMap<String, ArrayList<Locale>> mDictWords; + + /** + * The last-scheduled reload future. Saved in order to cancel a pending reload if a new one + * is coming. + */ + private volatile ScheduledFuture<?> mReloadFuture; + + /** + * @param context the context from which to obtain content resolver + */ + public UserDictionaryLookup(Context context) { + if (DEBUG) { + Log.d(TAG, "UserDictionaryLookup constructor with context: " + context); + } + + // Obtain a content resolver. + mResolver = context.getContentResolver(); + + // Schedule the initial load to run immediately. It's possible that the first call to + // isValidWord occurs before the dictionary has actually loaded, so it should not + // assume that the dictionary has been loaded. + ExecutorUtils.getBackgroundExecutor(ExecutorUtils.SPELLING).execute(mLoader); + + // Register the observer to be notified on changes to the UserDictionary and all individual + // items. + // + // If the user is interacting with the UserDictionary settings UI, or with the + // "Add to dictionary" drop-down option, duplicate notifications will be sent for the same + // edit: if a new entry is added, there is a notification for the entry itself, and + // separately for the entire dictionary. However, when used programmatically, + // only notifications for the specific edits are sent. Thus, the observer is registered to + // receive every possible notification, and instead has throttling logic to avoid doing too + // many reloads. + mResolver.registerContentObserver( + UserDictionary.Words.CONTENT_URI, true /* notifyForDescendents */, mObserver); + } + + /** + * To be called by the garbage collector in the off chance that the service did not clean up + * properly. Do not rely on this getting called, and make sure close() is called explicitly. + */ + @Override + public void finalize() throws Throwable { + try { + if (DEBUG) { + Log.d(TAG, "Finalize called, calling close()"); + } + close(); + } finally { + super.finalize(); + } + } + + /** + * Cleans up UserDictionaryLookup: shuts down any extra threads and unregisters the observer. + * + * It is safe, but not advised to call this multiple times, and isValidWord would continue to + * work, but no data will be reloaded any longer. + */ + @Override + public void close() { + if (DEBUG) { + Log.d(TAG, "Close called (no pun intended), cleaning up executor and observer"); + } + if (mIsClosed.compareAndSet(false, true)) { + // Unregister the content observer. + mResolver.unregisterContentObserver(mObserver); + } + } + + /** + * Returns true if the initial load has been performed. + * + * @return true if the initial load is successful + */ + @UsedForTesting + boolean isLoaded() { + return mDictWords != null; + } + + /** + * Determines if the given word is a valid word in the given locale based on the UserDictionary. + * It tries hard to find a match: for example, casing is ignored and if the word is present in a + * more general locale (e.g. en or all locales), and isValidWord is asking for a more specific + * locale (e.g. en_US), it will be considered a match. + * + * @param word the word to match + * @param locale the locale in which to match the word + * @return true iff the word has been matched for this locale in the UserDictionary. + */ + public boolean isValidWord( + final String word, final Locale locale) { + if (!isLoaded()) { + // This is a corner case in the event the initial load of UserDictionary has not + // been loaded. In that case, we assume the word is not a valid word in + // UserDictionary. + if (DEBUG) { + Log.d(TAG, "isValidWord invoked, but initial load not complete"); + } + return false; + } + + // Atomically obtain the current copy of mDictWords; + final HashMap<String, ArrayList<Locale>> dictWords = mDictWords; + + if (DEBUG) { + Log.d(TAG, "isValidWord invoked for word [" + word + + "] in locale " + locale); + } + // Lowercase the word using the given locale. Note, that dictionary + // words are lowercased using their locale, and theoretically the + // lowercasing between two matching locales may differ. For simplicity + // we ignore that possibility. + final String lowercased = word.toLowerCase(locale); + final ArrayList<Locale> dictLocales = dictWords.get(lowercased); + if (null == dictLocales) { + if (DEBUG) { + Log.d(TAG, "isValidWord=false, since there is no entry for " + + "lowercased word [" + lowercased + "]"); + } + return false; + } else { + if (DEBUG) { + Log.d(TAG, "isValidWord found an entry for lowercased word [" + lowercased + + "]; examining locales"); + } + // Iterate over the locales this word is in. + for (final Locale dictLocale : dictLocales) { + final int matchLevel = LocaleUtils.getMatchLevel(dictLocale.toString(), + locale.toString()); + if (DEBUG) { + Log.d(TAG, "matchLevel for dictLocale=" + dictLocale + ", locale=" + + locale + " is " + matchLevel); + } + if (LocaleUtils.isMatch(matchLevel)) { + if (DEBUG) { + Log.d(TAG, "isValidWord=true, since matchLevel " + matchLevel + + " is a match"); + } + return true; + } + if (DEBUG) { + Log.d(TAG, "matchLevel " + matchLevel + " is not a match"); + } + } + if (DEBUG) { + Log.d(TAG, "isValidWord=false, since none of the locales matched"); + } + return false; + } + } + + /** + * Loads the UserDictionary in the current thread. + * + * Only one reload can happen at a time. If already running, will exit quickly. + */ + private void loadUserDictionary() { + // Bail out if already in the process of loading. + if (!mIsLoading.compareAndSet(false, true)) { + if (DEBUG) { + Log.d(TAG, "Already in the process of loading UserDictionary, skipping"); + } + return; + } + if (DEBUG) { + Log.d(TAG, "Loading UserDictionary"); + } + HashMap<String, ArrayList<Locale>> dictWords = new HashMap<>(); + // Load the UserDictionary. Request that items be returned in the default sort order + // for UserDictionary, which is by frequency. + Cursor cursor = mResolver.query(UserDictionary.Words.CONTENT_URI, + null, null, null, UserDictionary.Words.DEFAULT_SORT_ORDER); + if (null == cursor || cursor.getCount() < 1) { + if (DEBUG) { + Log.d(TAG, "No entries found in UserDictionary"); + } + } else { + // Iterate over the entries in the UserDictionary. Note, that iteration is in + // descending frequency by default. + while (dictWords.size() < MAX_NUM_ENTRIES && cursor.moveToNext()) { + // If there is no column for locale, skip this entry. An empty + // locale on the other hand will not be skipped. + final int dictLocaleIndex = cursor.getColumnIndex( + UserDictionary.Words.LOCALE); + if (dictLocaleIndex < 0) { + if (DEBUG) { + Log.d(TAG, "Encountered UserDictionary entry " + + "without LOCALE, skipping"); + } + continue; + } + // If there is no column for word, skip this entry. + final int dictWordIndex = cursor.getColumnIndex( + UserDictionary.Words.WORD); + if (dictWordIndex < 0) { + if (DEBUG) { + Log.d(TAG, "Encountered UserDictionary entry without " + + "WORD, skipping"); + } + continue; + } + // If the word is null, skip this entry. + final String rawDictWord = cursor.getString(dictWordIndex); + if (null == rawDictWord) { + if (DEBUG) { + Log.d(TAG, "Encountered null word"); + } + continue; + } + // If the locale is null, that's interpreted to mean all locales. Note, the special + // zz locale for an Alphabet (QWERTY) layout will not match any actual language. + String localeString = cursor.getString(dictLocaleIndex); + if (null == localeString) { + if (DEBUG) { + Log.d(TAG, "Encountered null locale for word [" + + rawDictWord + "], assuming all locales"); + } + // For purposes of LocaleUtils, an empty locale matches + // everything. + localeString = ""; + } + final Locale dictLocale = LocaleUtils.constructLocaleFromString( + localeString); + // Lowercase the word before storing it. + final String dictWord = rawDictWord.toLowerCase(dictLocale); + if (DEBUG) { + Log.d(TAG, "Incorporating UserDictionary word [" + dictWord + + "] for locale " + dictLocale); + } + // Check if there is an existing entry for this word. + ArrayList<Locale> dictLocales = dictWords.get(dictWord); + if (null == dictLocales) { + // If there is no entry for this word, create one. + if (DEBUG) { + Log.d(TAG, "Word [" + dictWord + + "] not seen for other locales, creating new entry"); + } + dictLocales = new ArrayList<>(); + dictWords.put(dictWord, dictLocales); + } + // Append the locale to the list of locales this word is in. + dictLocales.add(dictLocale); + } + } + + // Atomically replace the copy of mDictWords. + mDictWords = dictWords; + + // Allow other calls to loadUserDictionary to execute now. + mIsLoading.set(false); + } +} |