From 87eb7ac29c51ba4c341cb663cdbbc5ea74595f2d Mon Sep 17 00:00:00 2001 From: Dan Zivkovic Date: Wed, 1 Apr 2015 19:18:52 -0700 Subject: Add shortcut support to UserDictionaryLookup. Also move the class to the parent package, since it's no longer tied to the spell checking service. Bug 19966848. Bug 20036810. Change-Id: I35014d212fd87281eb90def03ee92e6872dcd63e --- .../inputmethod/latin/common/CollectionUtils.java | 13 +- .../com/android/inputmethod/latin/Dictionary.java | 4 + .../inputmethod/latin/UserDictionaryLookup.java | 518 +++++++++++++++++++++ .../spellcheck/AndroidSpellCheckerService.java | 35 +- .../latin/spellcheck/UserDictionaryLookup.java | 420 ----------------- .../latin/UserDictionaryLookupTest.java | 352 ++++++++++++++ .../latin/spellcheck/UserDictionaryLookupTest.java | 279 ----------- .../latin/utils/CollectionUtilsTests.java | 15 +- 8 files changed, 899 insertions(+), 737 deletions(-) create mode 100644 java/src/com/android/inputmethod/latin/UserDictionaryLookup.java delete mode 100644 java/src/com/android/inputmethod/latin/spellcheck/UserDictionaryLookup.java create mode 100644 tests/src/com/android/inputmethod/latin/UserDictionaryLookupTest.java delete mode 100644 tests/src/com/android/inputmethod/latin/spellcheck/UserDictionaryLookupTest.java diff --git a/common/src/com/android/inputmethod/latin/common/CollectionUtils.java b/common/src/com/android/inputmethod/latin/common/CollectionUtils.java index 48df413fd..80fae5f51 100644 --- a/common/src/com/android/inputmethod/latin/common/CollectionUtils.java +++ b/common/src/com/android/inputmethod/latin/common/CollectionUtils.java @@ -20,6 +20,7 @@ import com.android.inputmethod.annotations.UsedForTesting; import java.util.ArrayList; import java.util.Collection; +import java.util.Map; import javax.annotation.Nonnull; import javax.annotation.Nullable; @@ -60,7 +61,17 @@ public final class CollectionUtils { * @return Whether c contains no elements. */ @UsedForTesting - public static boolean isNullOrEmpty(@Nullable final Collection c) { + public static boolean isNullOrEmpty(@Nullable final Collection c) { return c == null || c.isEmpty(); } + + /** + * Tests whether map contains no elements, true if map is null or map is empty. + * @param map Map to test. + * @return Whether map contains no elements. + */ + @UsedForTesting + public static boolean isNullOrEmpty(@Nullable final Map map) { + return map == null || map.isEmpty(); + } } diff --git a/java/src/com/android/inputmethod/latin/Dictionary.java b/java/src/com/android/inputmethod/latin/Dictionary.java index 16dcb3208..e00219c98 100644 --- a/java/src/com/android/inputmethod/latin/Dictionary.java +++ b/java/src/com/android/inputmethod/latin/Dictionary.java @@ -39,6 +39,10 @@ public abstract class Dictionary { public static final String TYPE_USER_TYPED = "user_typed"; public static final PhonyDictionary DICTIONARY_USER_TYPED = new PhonyDictionary(TYPE_USER_TYPED); + public static final String TYPE_USER_SHORTCUT = "user_shortcut"; + public static final PhonyDictionary DICTIONARY_USER_SHORTCUT = + new PhonyDictionary(TYPE_USER_SHORTCUT); + public static final String TYPE_APPLICATION_DEFINED = "application_defined"; public static final PhonyDictionary DICTIONARY_APPLICATION_DEFINED = new PhonyDictionary(TYPE_APPLICATION_DEFINED); diff --git a/java/src/com/android/inputmethod/latin/UserDictionaryLookup.java b/java/src/com/android/inputmethod/latin/UserDictionaryLookup.java new file mode 100644 index 000000000..2569723b0 --- /dev/null +++ b/java/src/com/android/inputmethod/latin/UserDictionaryLookup.java @@ -0,0 +1,518 @@ +/* + * 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; + +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.text.TextUtils; +import android.util.Log; + +import com.android.inputmethod.annotations.UsedForTesting; +import com.android.inputmethod.latin.common.CollectionUtils; +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; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * 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 { + + /** + * 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; + + @UsedForTesting + static final Locale ANY_LOCALE = new Locale(""); + + private final String mTag; + private final ContentResolver mResolver; + private final String mServiceName; + + /** + * Runnable that calls loadUserDictionary(). + */ + private class UserDictionaryLoader implements Runnable { + @Override + public void run() { + if (DEBUG) { + Log.d(mTag, "Executing (re)load"); + } + loadUserDictionary(); + } + } + + /** + * 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(mTag, "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(mTag, "Successfully canceled previous reload request"); + } else { + Log.d(mTag, "Unable to cancel previous reload request"); + } + } + } + + if (DEBUG) { + Log.d(mTag, "Scheduling reload in " + RELOAD_DELAY_MS + " ms"); + } + + // Schedule a new reload after RELOAD_DELAY_MS. + mReloadFuture = ExecutorUtils.getBackgroundExecutor(mServiceName) + .schedule(new UserDictionaryLoader(), 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> mDictWords; + + /** + * We store a map from a shortcut to a word for each locale. + * Shortcuts that apply to any locale are keyed by {@link #ANY_LOCALE}. + */ + private volatile HashMap> mShortcutsPerLocale; + + /** + * 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(@Nonnull final Context context, @Nonnull final String serviceName) { + mTag = serviceName + ".UserDictionaryLookup"; + + Log.i(mTag, "create()"); + + mServiceName = serviceName; + + // Obtain a content resolver. + mResolver = context.getContentResolver(); + } + + public void open() { + Log.i(mTag, "open()"); + + // 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. + loadUserDictionary(); + + // 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(mTag, "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(mTag, "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 + */ + public boolean isLoaded() { + return mDictWords != null && mShortcutsPerLocale != 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(@Nonnull final String word, @Nonnull 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(mTag, "isValidWord invoked, but initial load not complete"); + } + return false; + } + + // Atomically obtain the current copy of mDictWords; + final HashMap> dictWords = mDictWords; + + if (DEBUG) { + Log.d(mTag, "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 dictLocales = dictWords.get(lowercased); + if (null == dictLocales) { + if (DEBUG) { + Log.d(mTag, "isValidWord=false, since there is no entry for " + + "lowercased word [" + lowercased + "]"); + } + return false; + } else { + if (DEBUG) { + Log.d(mTag, "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(mTag, "matchLevel for dictLocale=" + dictLocale + ", locale=" + + locale + " is " + matchLevel); + } + if (LocaleUtils.isMatch(matchLevel)) { + if (DEBUG) { + Log.d(mTag, "isValidWord=true, since matchLevel " + matchLevel + + " is a match"); + } + return true; + } + if (DEBUG) { + Log.d(mTag, "matchLevel " + matchLevel + " is not a match"); + } + } + if (DEBUG) { + Log.d(mTag, "isValidWord=false, since none of the locales matched"); + } + return false; + } + } + + /** + * Expands the given shortcut for the given locale. + * + * @param shortcut the shortcut to expand + * @param inputLocale the locale in which to expand the shortcut + * @return expanded shortcut iff the word is a shortcut in the UserDictionary. + */ + @Nullable public String expandShortcut( + @Nonnull final String shortcut, @Nonnull final Locale inputLocale) { + if (DEBUG) { + Log.d(mTag, "expandShortcut() : Shortcut [" + shortcut + "] for [" + inputLocale + "]"); + } + + // Atomically obtain the current copy of mShortcuts; + final HashMap> shortcutsPerLocale = mShortcutsPerLocale; + + // Exit as early as possible. Most users don't use shortcuts. + if (CollectionUtils.isNullOrEmpty(shortcutsPerLocale)) { + return null; + } + + if (!TextUtils.isEmpty(inputLocale.getCountry())) { + // First look for the country-specific shortcut: en_US, en_UK, fr_FR, etc. + final String expansionForCountry = expandShortcut( + shortcutsPerLocale, shortcut, inputLocale); + if (!TextUtils.isEmpty(expansionForCountry)) { + return expansionForCountry; + } + } + + // Next look for the language-specific shortcut: en, fr, etc. + final Locale languageOnlyLocale = + LocaleUtils.constructLocaleFromString(inputLocale.getLanguage()); + final String expansionForLanguage = expandShortcut( + shortcutsPerLocale, shortcut, languageOnlyLocale); + if (!TextUtils.isEmpty(expansionForLanguage)) { + return expansionForLanguage; + } + + // If all else fails, loof for a global shortcut. + return expandShortcut(shortcutsPerLocale, shortcut, ANY_LOCALE); + } + + @Nullable private String expandShortcut( + @Nullable final HashMap> shortcutsPerLocale, + @Nonnull final String shortcut, + @Nonnull final Locale locale) { + if (CollectionUtils.isNullOrEmpty(shortcutsPerLocale)) { + return null; + } + final HashMap localeShortcuts = shortcutsPerLocale.get(locale); + if (CollectionUtils.isNullOrEmpty(localeShortcuts)) { + return null; + } + final String word = localeShortcuts.get(shortcut); + if (DEBUG && word != null) { + Log.d(mTag, "expandShortcut() : Shortcut [" + shortcut + "] for [" + locale + + "] expands to [" + word + "]"); + } + return word; + } + + /** + * 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)) { + Log.i(mTag, "loadUserDictionary() : Already Loading (exit)"); + return; + } + Log.i(mTag, "loadUserDictionary() : Start Loading"); + HashMap> dictWords = new HashMap<>(); + HashMap> shortcutsPerLocale = 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) { + Log.i(mTag, "loadUserDictionary() : Empty"); + } 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(mTag, "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(mTag, "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(mTag, "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(mTag, "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(mTag, "Incorporating UserDictionary word [" + dictWord + + "] for locale " + dictLocale); + } + // Check if there is an existing entry for this word. + ArrayList dictLocales = dictWords.get(dictWord); + if (null == dictLocales) { + // If there is no entry for this word, create one. + if (DEBUG) { + Log.d(mTag, "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); + + // If there is no column for a shortcut, we're done. + final int shortcutIndex = cursor.getColumnIndex(UserDictionary.Words.SHORTCUT); + if (shortcutIndex < 0) { + if (DEBUG) { + Log.d(mTag, "Encountered UserDictionary entry without SHORTCUT, done"); + } + continue; + } + // If the shortcut is null, we're done. + final String shortcut = cursor.getString(shortcutIndex); + if (shortcut == null) { + if (DEBUG) { + Log.d(mTag, "Encountered null shortcut"); + } + continue; + } + // Else, save the shortcut. + HashMap localeShortcuts = shortcutsPerLocale.get(dictLocale); + if (localeShortcuts == null) { + localeShortcuts = new HashMap<>(); + shortcutsPerLocale.put(dictLocale, localeShortcuts); + } + // Map to the raw input, which might be capitalized. + // This lets the user create a shortcut from "gm" to "General Motors". + localeShortcuts.put(shortcut, rawDictWord); + } + } + + // Atomically replace the copy of mDictWords and mShortcuts. + mDictWords = dictWords; + mShortcutsPerLocale = shortcutsPerLocale; + + // Allow other calls to loadUserDictionary to execute now. + mIsLoading.set(false); + + Log.i(mTag, "loadUserDictionary() : Loaded " + mDictWords.size() + " words"); + } +} diff --git a/java/src/com/android/inputmethod/latin/spellcheck/AndroidSpellCheckerService.java b/java/src/com/android/inputmethod/latin/spellcheck/AndroidSpellCheckerService.java index 766b385a9..4625e8e8b 100644 --- a/java/src/com/android/inputmethod/latin/spellcheck/AndroidSpellCheckerService.java +++ b/java/src/com/android/inputmethod/latin/spellcheck/AndroidSpellCheckerService.java @@ -24,7 +24,6 @@ import android.text.InputType; 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; @@ -83,7 +82,6 @@ public final class AndroidSpellCheckerService extends SpellCheckerService public static final String SINGLE_QUOTE = "\u0027"; public static final String APOSTROPHE = "\u2019"; - private UserDictionaryLookup mUserDictionaryLookup; public AndroidSpellCheckerService() { super(); @@ -95,30 +93,11 @@ public final class AndroidSpellCheckerService extends SpellCheckerService @Override public void onCreate() { super.onCreate(); - mRecommendedThreshold = - Float.parseFloat(getString(R.string.spellchecker_recommended_threshold_value)); + 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() { @@ -181,16 +160,6 @@ 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 = mDictionaryFacilitatorCache.get(locale); return dictionaryFacilitatorForLocale.isValidSpellingWord(word); diff --git a/java/src/com/android/inputmethod/latin/spellcheck/UserDictionaryLookup.java b/java/src/com/android/inputmethod/latin/spellcheck/UserDictionaryLookup.java deleted file mode 100644 index f2491f478..000000000 --- a/java/src/com/android/inputmethod/latin/spellcheck/UserDictionaryLookup.java +++ /dev/null @@ -1,420 +0,0 @@ -/* - * 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> 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> 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 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> 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 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); - } -} diff --git a/tests/src/com/android/inputmethod/latin/UserDictionaryLookupTest.java b/tests/src/com/android/inputmethod/latin/UserDictionaryLookupTest.java new file mode 100644 index 000000000..d8060f286 --- /dev/null +++ b/tests/src/com/android/inputmethod/latin/UserDictionaryLookupTest.java @@ -0,0 +1,352 @@ +/* + * 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; + +import static com.android.inputmethod.latin.UserDictionaryLookup.ANY_LOCALE; + +import android.annotation.SuppressLint; +import android.content.ContentResolver; +import android.database.Cursor; +import android.net.Uri; +import android.provider.UserDictionary; +import android.test.AndroidTestCase; +import android.test.suitebuilder.annotation.SmallTest; +import android.util.Log; + +import com.android.inputmethod.latin.utils.ExecutorUtils; + +import java.util.HashSet; +import java.util.Locale; + +/** + * Unit tests for {@link com.android.inputmethod.latin.UserDictionaryLookup}. + * + * Note, this test doesn't mock out the ContentResolver, in order to make sure UserDictionaryLookup + * works in a real setting. + */ +@SmallTest +public class UserDictionaryLookupTest extends AndroidTestCase { + private static final String TAG = UserDictionaryLookupTest.class.getSimpleName(); + + private ContentResolver mContentResolver; + private HashSet mAddedBackup; + + @Override + protected void setUp() throws Exception { + super.setUp(); + mContentResolver = mContext.getContentResolver(); + mAddedBackup = new HashSet(); + } + + @Override + protected void tearDown() throws Exception { + // Remove all entries added during this test. + for (Uri row : mAddedBackup) { + mContentResolver.delete(row, null, null); + } + mAddedBackup.clear(); + + super.tearDown(); + } + + /** + * Adds the given word to UserDictionary. + * + * @param word the word to add + * @param locale the locale of the word to add + * @param frequency the frequency of the word to add + * @return the Uri for the given word + */ + @SuppressLint("NewApi") + private Uri addWord(final String word, final Locale locale, int frequency, String shortcut) { + // Add the given word for the given locale. + UserDictionary.Words.addWord(mContext, word, frequency, shortcut, locale); + // Obtain an Uri for the given word. + Cursor cursor = mContentResolver.query(UserDictionary.Words.CONTENT_URI, null, + UserDictionary.Words.WORD + "='" + word + "'", null, null); + assertTrue(cursor.moveToFirst()); + Uri uri = Uri.withAppendedPath(UserDictionary.Words.CONTENT_URI, + cursor.getString(cursor.getColumnIndex(UserDictionary.Words._ID))); + // Add the row to the backup for later clearing. + mAddedBackup.add(uri); + return uri; + } + + /** + * Deletes the entry for the given word from UserDictionary. + * + * @param uri the Uri for the word as returned by addWord + */ + private void deleteWord(Uri uri) { + // Remove the word from the backup so that it's not cleared again later. + mAddedBackup.remove(uri); + // Remove the word from UserDictionary. + mContentResolver.delete(uri, null, null); + } + + private UserDictionaryLookup setUpShortcut(final Locale locale) { + // Insert "shortcut" => "Expansion" in the UserDictionary for the given locale. + addWord("Expansion", locale, 17, "shortcut"); + + // Create the UserDictionaryLookup and wait until it's loaded. + UserDictionaryLookup lookup = new UserDictionaryLookup(mContext, ExecutorUtils.SPELLING); + lookup.open(); + while (!lookup.isLoaded()) { + } + return lookup; + } + + public void testShortcutKeyMatching() { + Log.d(TAG, "testShortcutKeyMatching"); + UserDictionaryLookup lookup = setUpShortcut(Locale.US); + + assertEquals("Expansion", lookup.expandShortcut("shortcut", Locale.US)); + assertNull(lookup.expandShortcut("Shortcut", Locale.US)); + assertNull(lookup.expandShortcut("SHORTCUT", Locale.US)); + assertNull(lookup.expandShortcut("shortcu", Locale.US)); + assertNull(lookup.expandShortcut("shortcutt", Locale.US)); + + lookup.close(); + } + + public void testShortcutMatchesInputCountry() { + Log.d(TAG, "testShortcutMatchesInputCountry"); + UserDictionaryLookup lookup = setUpShortcut(Locale.US); + + assertEquals("Expansion", lookup.expandShortcut("shortcut", Locale.US)); + assertNull(lookup.expandShortcut("shortcut", Locale.UK)); + assertNull(lookup.expandShortcut("shortcut", Locale.ENGLISH)); + assertNull(lookup.expandShortcut("shortcut", Locale.FRENCH)); + assertNull(lookup.expandShortcut("shortcut", ANY_LOCALE)); + + lookup.close(); + } + + public void testShortcutMatchesInputLanguage() { + Log.d(TAG, "testShortcutMatchesInputLanguage"); + UserDictionaryLookup lookup = setUpShortcut(Locale.ENGLISH); + + assertEquals("Expansion", lookup.expandShortcut("shortcut", Locale.US)); + assertEquals("Expansion", lookup.expandShortcut("shortcut", Locale.UK)); + assertEquals("Expansion", lookup.expandShortcut("shortcut", Locale.ENGLISH)); + assertNull(lookup.expandShortcut("shortcut", Locale.FRENCH)); + assertNull(lookup.expandShortcut("shortcut", ANY_LOCALE)); + + lookup.close(); + } + + public void testShortcutMatchesAnyLocale() { + UserDictionaryLookup lookup = setUpShortcut(UserDictionaryLookup.ANY_LOCALE); + + assertEquals("Expansion", lookup.expandShortcut("shortcut", Locale.US)); + assertEquals("Expansion", lookup.expandShortcut("shortcut", Locale.UK)); + assertEquals("Expansion", lookup.expandShortcut("shortcut", Locale.ENGLISH)); + assertEquals("Expansion", lookup.expandShortcut("shortcut", Locale.FRENCH)); + assertEquals("Expansion", lookup.expandShortcut("shortcut", ANY_LOCALE)); + + lookup.close(); + } + + public void testExactLocaleMatch() { + Log.d(TAG, "testExactLocaleMatch"); + + // Insert "Foo" as capitalized in the UserDictionary under en_US locale. + addWord("Foo", Locale.US, 17, null); + + // Create the UserDictionaryLookup and wait until it's loaded. + UserDictionaryLookup lookup = new UserDictionaryLookup(mContext, ExecutorUtils.SPELLING); + lookup.open(); + while (!lookup.isLoaded()) { + } + + // Any capitalization variation should match. + assertTrue(lookup.isValidWord("foo", Locale.US)); + assertTrue(lookup.isValidWord("Foo", Locale.US)); + assertTrue(lookup.isValidWord("FOO", Locale.US)); + // But similar looking words don't match. + assertFalse(lookup.isValidWord("fo", Locale.US)); + assertFalse(lookup.isValidWord("fop", Locale.US)); + assertFalse(lookup.isValidWord("fooo", Locale.US)); + // Other locales, including more general locales won't match. + assertFalse(lookup.isValidWord("foo", Locale.ENGLISH)); + assertFalse(lookup.isValidWord("foo", Locale.UK)); + assertFalse(lookup.isValidWord("foo", Locale.FRENCH)); + assertFalse(lookup.isValidWord("foo", ANY_LOCALE)); + + lookup.close(); + } + + public void testSubLocaleMatch() { + Log.d(TAG, "testSubLocaleMatch"); + + // Insert "Foo" as capitalized in the UserDictionary under the en locale. + addWord("Foo", Locale.ENGLISH, 17, null); + + // Create the UserDictionaryLookup and wait until it's loaded. + UserDictionaryLookup lookup = new UserDictionaryLookup(mContext, ExecutorUtils.SPELLING); + lookup.open(); + while (!lookup.isLoaded()) { + } + + // Any capitalization variation should match for both en and en_US. + assertTrue(lookup.isValidWord("foo", Locale.ENGLISH)); + assertTrue(lookup.isValidWord("foo", Locale.US)); + assertTrue(lookup.isValidWord("Foo", Locale.US)); + assertTrue(lookup.isValidWord("FOO", Locale.US)); + // But similar looking words don't match. + assertFalse(lookup.isValidWord("fo", Locale.US)); + assertFalse(lookup.isValidWord("fop", Locale.US)); + assertFalse(lookup.isValidWord("fooo", Locale.US)); + + lookup.close(); + } + + public void testAllLocalesMatch() { + Log.d(TAG, "testAllLocalesMatch"); + + // Insert "Foo" as capitalized in the UserDictionary under the all locales. + addWord("Foo", null, 17, null); + + // Create the UserDictionaryLookup and wait until it's loaded. + UserDictionaryLookup lookup = new UserDictionaryLookup(mContext, ExecutorUtils.SPELLING); + lookup.open(); + while (!lookup.isLoaded()) { + } + + // Any capitalization variation should match for fr, en and en_US. + assertTrue(lookup.isValidWord("foo", ANY_LOCALE)); + assertTrue(lookup.isValidWord("foo", Locale.FRENCH)); + assertTrue(lookup.isValidWord("foo", Locale.ENGLISH)); + assertTrue(lookup.isValidWord("foo", Locale.US)); + assertTrue(lookup.isValidWord("Foo", Locale.US)); + assertTrue(lookup.isValidWord("FOO", Locale.US)); + // But similar looking words don't match. + assertFalse(lookup.isValidWord("fo", Locale.US)); + assertFalse(lookup.isValidWord("fop", Locale.US)); + assertFalse(lookup.isValidWord("fooo", Locale.US)); + + lookup.close(); + } + + public void testMultipleLocalesMatch() { + Log.d(TAG, "testMultipleLocalesMatch"); + + // Insert "Foo" as capitalized in the UserDictionary under the en_US and en_CA and fr + // locales. + addWord("Foo", Locale.US, 17, null); + addWord("foO", Locale.CANADA, 17, null); + addWord("fOo", Locale.FRENCH, 17, null); + + // Create the UserDictionaryLookup and wait until it's loaded. + UserDictionaryLookup lookup = new UserDictionaryLookup(mContext, ExecutorUtils.SPELLING); + lookup.open(); + while (!lookup.isLoaded()) { + } + + // Both en_CA and en_US match. + assertTrue(lookup.isValidWord("foo", Locale.CANADA)); + assertTrue(lookup.isValidWord("foo", Locale.US)); + assertTrue(lookup.isValidWord("foo", Locale.FRENCH)); + // Other locales, including more general locales won't match. + assertFalse(lookup.isValidWord("foo", Locale.ENGLISH)); + assertFalse(lookup.isValidWord("foo", Locale.UK)); + assertFalse(lookup.isValidWord("foo", ANY_LOCALE)); + + lookup.close(); + } + + public void testReload() { + Log.d(TAG, "testReload"); + + // Insert "foo". + Uri uri = addWord("foo", Locale.US, 17, null); + + // Create the UserDictionaryLookup and wait until it's loaded. + UserDictionaryLookup lookup = new UserDictionaryLookup(mContext, ExecutorUtils.SPELLING); + lookup.open(); + while (!lookup.isLoaded()) { + } + + // "foo" should match. + assertTrue(lookup.isValidWord("foo", Locale.US)); + + // "bar" shouldn't match. + assertFalse(lookup.isValidWord("bar", Locale.US)); + + // Now delete "foo" and add "bar". + deleteWord(uri); + addWord("bar", Locale.US, 18, null); + + // Wait a little bit before expecting a change. The time we wait should be greater than + // UserDictionaryLookup.RELOAD_DELAY_MS. + try { + Thread.sleep(UserDictionaryLookup.RELOAD_DELAY_MS + 1000); + } catch (InterruptedException e) { + } + + // Perform lookups again. Reload should have occured. + // + // "foo" should not match. + assertFalse(lookup.isValidWord("foo", Locale.US)); + + // "bar" should match. + assertTrue(lookup.isValidWord("bar", Locale.US)); + + lookup.close(); + } + + public void testClose() { + Log.d(TAG, "testClose"); + + // Insert "foo". + Uri uri = addWord("foo", Locale.US, 17, null); + + // Create the UserDictionaryLookup and wait until it's loaded. + UserDictionaryLookup lookup = new UserDictionaryLookup(mContext, ExecutorUtils.SPELLING); + lookup.open(); + while (!lookup.isLoaded()) { + } + + // "foo" should match. + assertTrue(lookup.isValidWord("foo", Locale.US)); + + // "bar" shouldn't match. + assertFalse(lookup.isValidWord("bar", Locale.US)); + + // Now close (prevents further reloads). + lookup.close(); + + // Now delete "foo" and add "bar". + deleteWord(uri); + addWord("bar", Locale.US, 18, null); + + // Wait a little bit before expecting a change. The time we wait should be greater than + // UserDictionaryLookup.RELOAD_DELAY_MS. + try { + Thread.sleep(UserDictionaryLookup.RELOAD_DELAY_MS + 1000); + } catch (InterruptedException e) { + } + + // Perform lookups again. Reload should not have occurred. + // + // "foo" should stil match. + assertTrue(lookup.isValidWord("foo", Locale.US)); + + // "bar" should still not match. + assertFalse(lookup.isValidWord("bar", Locale.US)); + } +} diff --git a/tests/src/com/android/inputmethod/latin/spellcheck/UserDictionaryLookupTest.java b/tests/src/com/android/inputmethod/latin/spellcheck/UserDictionaryLookupTest.java deleted file mode 100644 index e5c813942..000000000 --- a/tests/src/com/android/inputmethod/latin/spellcheck/UserDictionaryLookupTest.java +++ /dev/null @@ -1,279 +0,0 @@ -/* - * 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.annotation.SuppressLint; -import android.content.ContentResolver; -import android.database.Cursor; -import android.net.Uri; -import android.provider.UserDictionary; -import android.test.AndroidTestCase; -import android.test.suitebuilder.annotation.SmallTest; -import android.util.Log; - -import java.util.HashSet; -import java.util.Locale; - -/** - * Unit tests for {@link UserDictionaryLookup}. - * - * Note, this test doesn't mock out the ContentResolver, in order to make sure UserDictionaryLookup - * works in a real setting. - */ -@SmallTest -public class UserDictionaryLookupTest extends AndroidTestCase { - private static final String TAG = UserDictionaryLookupTest.class.getSimpleName(); - - private ContentResolver mContentResolver; - private HashSet mAddedBackup; - - @Override - protected void setUp() throws Exception { - super.setUp(); - mContentResolver = mContext.getContentResolver(); - mAddedBackup = new HashSet(); - } - - @Override - protected void tearDown() throws Exception { - // Remove all entries added during this test. - for (Uri row : mAddedBackup) { - mContentResolver.delete(row, null, null); - } - mAddedBackup.clear(); - - super.tearDown(); - } - - /** - * Adds the given word to UserDictionary. - * - * @param word the word to add - * @param locale the locale of the word to add - * @param frequency the frequency of the word to add - * @return the Uri for the given word - */ - @SuppressLint("NewApi") - private Uri addWord(final String word, final Locale locale, int frequency) { - // Add the given word for the given locale. - UserDictionary.Words.addWord(mContext, word, frequency, null, locale); - // Obtain an Uri for the given word. - Cursor cursor = mContentResolver.query(UserDictionary.Words.CONTENT_URI, null, - UserDictionary.Words.WORD + "='" + word + "'", null, null); - assertTrue(cursor.moveToFirst()); - Uri uri = Uri.withAppendedPath(UserDictionary.Words.CONTENT_URI, - cursor.getString(cursor.getColumnIndex(UserDictionary.Words._ID))); - // Add the row to the backup for later clearing. - mAddedBackup.add(uri); - return uri; - } - - /** - * Deletes the entry for the given word from UserDictionary. - * - * @param uri the Uri for the word as returned by addWord - */ - private void deleteWord(Uri uri) { - // Remove the word from the backup so that it's not cleared again later. - mAddedBackup.remove(uri); - // Remove the word from UserDictionary. - mContentResolver.delete(uri, null, null); - } - - public void testExactLocaleMatch() { - Log.d(TAG, "testExactLocaleMatch"); - - // Insert "Foo" as capitalized in the UserDictionary under en_US locale. - addWord("Foo", Locale.US, 17); - - // Create the UserDictionaryLookup and wait until it's loaded. - UserDictionaryLookup lookup = new UserDictionaryLookup(mContext); - while (!lookup.isLoaded()) { - } - - // Any capitalization variation should match. - assertTrue(lookup.isValidWord("foo", Locale.US)); - assertTrue(lookup.isValidWord("Foo", Locale.US)); - assertTrue(lookup.isValidWord("FOO", Locale.US)); - // But similar looking words don't match. - assertFalse(lookup.isValidWord("fo", Locale.US)); - assertFalse(lookup.isValidWord("fop", Locale.US)); - assertFalse(lookup.isValidWord("fooo", Locale.US)); - // Other locales, including more general locales won't match. - assertFalse(lookup.isValidWord("foo", Locale.ENGLISH)); - assertFalse(lookup.isValidWord("foo", Locale.UK)); - assertFalse(lookup.isValidWord("foo", Locale.FRENCH)); - assertFalse(lookup.isValidWord("foo", new Locale(""))); - - lookup.close(); - } - - public void testSubLocaleMatch() { - Log.d(TAG, "testSubLocaleMatch"); - - // Insert "Foo" as capitalized in the UserDictionary under the en locale. - addWord("Foo", Locale.ENGLISH, 17); - - // Create the UserDictionaryLookup and wait until it's loaded. - UserDictionaryLookup lookup = new UserDictionaryLookup(mContext); - while (!lookup.isLoaded()) { - } - - // Any capitalization variation should match for both en and en_US. - assertTrue(lookup.isValidWord("foo", Locale.ENGLISH)); - assertTrue(lookup.isValidWord("foo", Locale.US)); - assertTrue(lookup.isValidWord("Foo", Locale.US)); - assertTrue(lookup.isValidWord("FOO", Locale.US)); - // But similar looking words don't match. - assertFalse(lookup.isValidWord("fo", Locale.US)); - assertFalse(lookup.isValidWord("fop", Locale.US)); - assertFalse(lookup.isValidWord("fooo", Locale.US)); - - lookup.close(); - } - - public void testAllLocalesMatch() { - Log.d(TAG, "testAllLocalesMatch"); - - // Insert "Foo" as capitalized in the UserDictionary under the all locales. - addWord("Foo", null, 17); - - // Create the UserDictionaryLookup and wait until it's loaded. - UserDictionaryLookup lookup = new UserDictionaryLookup(mContext); - while (!lookup.isLoaded()) { - } - - // Any capitalization variation should match for fr, en and en_US. - assertTrue(lookup.isValidWord("foo", new Locale(""))); - assertTrue(lookup.isValidWord("foo", Locale.FRENCH)); - assertTrue(lookup.isValidWord("foo", Locale.ENGLISH)); - assertTrue(lookup.isValidWord("foo", Locale.US)); - assertTrue(lookup.isValidWord("Foo", Locale.US)); - assertTrue(lookup.isValidWord("FOO", Locale.US)); - // But similar looking words don't match. - assertFalse(lookup.isValidWord("fo", Locale.US)); - assertFalse(lookup.isValidWord("fop", Locale.US)); - assertFalse(lookup.isValidWord("fooo", Locale.US)); - - lookup.close(); - } - - public void testMultipleLocalesMatch() { - Log.d(TAG, "testMultipleLocalesMatch"); - - // Insert "Foo" as capitalized in the UserDictionary under the en_US and en_CA and fr - // locales. - addWord("Foo", Locale.US, 17); - addWord("foO", Locale.CANADA, 17); - addWord("fOo", Locale.FRENCH, 17); - - // Create the UserDictionaryLookup and wait until it's loaded. - UserDictionaryLookup lookup = new UserDictionaryLookup(mContext); - while (!lookup.isLoaded()) { - } - - // Both en_CA and en_US match. - assertTrue(lookup.isValidWord("foo", Locale.CANADA)); - assertTrue(lookup.isValidWord("foo", Locale.US)); - assertTrue(lookup.isValidWord("foo", Locale.FRENCH)); - // Other locales, including more general locales won't match. - assertFalse(lookup.isValidWord("foo", Locale.ENGLISH)); - assertFalse(lookup.isValidWord("foo", Locale.UK)); - assertFalse(lookup.isValidWord("foo", new Locale(""))); - - lookup.close(); - } - - public void testReload() { - Log.d(TAG, "testReload"); - - // Insert "foo". - Uri uri = addWord("foo", Locale.US, 17); - - // Create the UserDictionaryLookup and wait until it's loaded. - UserDictionaryLookup lookup = new UserDictionaryLookup(mContext); - while (!lookup.isLoaded()) { - } - - // "foo" should match. - assertTrue(lookup.isValidWord("foo", Locale.US)); - - // "bar" shouldn't match. - assertFalse(lookup.isValidWord("bar", Locale.US)); - - // Now delete "foo" and add "bar". - deleteWord(uri); - addWord("bar", Locale.US, 18); - - // Wait a little bit before expecting a change. The time we wait should be greater than - // UserDictionaryLookup.RELOAD_DELAY_MS. - try { - Thread.sleep(UserDictionaryLookup.RELOAD_DELAY_MS + 1000); - } catch (InterruptedException e) { - } - - // Perform lookups again. Reload should have occured. - // - // "foo" should not match. - assertFalse(lookup.isValidWord("foo", Locale.US)); - - // "bar" should match. - assertTrue(lookup.isValidWord("bar", Locale.US)); - - lookup.close(); - } - - public void testClose() { - Log.d(TAG, "testClose"); - - // Insert "foo". - Uri uri = addWord("foo", Locale.US, 17); - - // Create the UserDictionaryLookup and wait until it's loaded. - UserDictionaryLookup lookup = new UserDictionaryLookup(mContext); - while (!lookup.isLoaded()) { - } - - // "foo" should match. - assertTrue(lookup.isValidWord("foo", Locale.US)); - - // "bar" shouldn't match. - assertFalse(lookup.isValidWord("bar", Locale.US)); - - // Now close (prevents further reloads). - lookup.close(); - - // Now delete "foo" and add "bar". - deleteWord(uri); - addWord("bar", Locale.US, 18); - - // Wait a little bit before expecting a change. The time we wait should be greater than - // UserDictionaryLookup.RELOAD_DELAY_MS. - try { - Thread.sleep(UserDictionaryLookup.RELOAD_DELAY_MS + 1000); - } catch (InterruptedException e) { - } - - // Perform lookups again. Reload should not have occurred. - // - // "foo" should stil match. - assertTrue(lookup.isValidWord("foo", Locale.US)); - - // "bar" should still not match. - assertFalse(lookup.isValidWord("bar", Locale.US)); - } -} diff --git a/tests/src/com/android/inputmethod/latin/utils/CollectionUtilsTests.java b/tests/src/com/android/inputmethod/latin/utils/CollectionUtilsTests.java index 47fd5feaa..6871a41d5 100644 --- a/tests/src/com/android/inputmethod/latin/utils/CollectionUtilsTests.java +++ b/tests/src/com/android/inputmethod/latin/utils/CollectionUtilsTests.java @@ -24,6 +24,9 @@ import com.android.inputmethod.latin.common.CollectionUtils; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; /** * Tests for {@link CollectionUtils}. @@ -79,9 +82,13 @@ public class CollectionUtilsTests extends AndroidTestCase { * results for a few cases. */ public void testIsNullOrEmpty() { - assertTrue(CollectionUtils.isNullOrEmpty(null)); - assertTrue(CollectionUtils.isNullOrEmpty(new ArrayList<>())); - assertTrue(CollectionUtils.isNullOrEmpty(Collections.EMPTY_SET)); - assertFalse(CollectionUtils.isNullOrEmpty(Collections.singleton("Not empty"))); + assertTrue(CollectionUtils.isNullOrEmpty((List) null)); + assertTrue(CollectionUtils.isNullOrEmpty((Map) null)); + assertTrue(CollectionUtils.isNullOrEmpty(new ArrayList())); + assertTrue(CollectionUtils.isNullOrEmpty(new HashMap())); + assertTrue(CollectionUtils.isNullOrEmpty(Collections.EMPTY_LIST)); + assertTrue(CollectionUtils.isNullOrEmpty(Collections.EMPTY_MAP)); + assertFalse(CollectionUtils.isNullOrEmpty(Collections.singletonList("Not empty"))); + assertFalse(CollectionUtils.isNullOrEmpty(Collections.singletonMap("Not", "empty"))); } } -- cgit v1.2.3-83-g751a