am 2979fad2: Merge "Add new class spellcheck.UserDictionaryLookup that can look up the system "Personal dictionary" in the event that the DictionaryFacilitator doesn\'t."
* commit '2979fad21384bb595ba2baca8f5bbbfc053be921': Add new class spellcheck.UserDictionaryLookup that can look up the system "Personal dictionary" in the event that the DictionaryFacilitator doesn't.main
commit
1c7d6283fc
|
@ -24,6 +24,7 @@ import android.text.InputType;
|
||||||
import android.view.inputmethod.EditorInfo;
|
import android.view.inputmethod.EditorInfo;
|
||||||
import android.view.inputmethod.InputMethodSubtype;
|
import android.view.inputmethod.InputMethodSubtype;
|
||||||
import android.view.textservice.SuggestionsInfo;
|
import android.view.textservice.SuggestionsInfo;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
import com.android.inputmethod.keyboard.Keyboard;
|
import com.android.inputmethod.keyboard.Keyboard;
|
||||||
import com.android.inputmethod.keyboard.KeyboardId;
|
import com.android.inputmethod.keyboard.KeyboardId;
|
||||||
|
@ -52,6 +53,9 @@ import java.util.concurrent.Semaphore;
|
||||||
*/
|
*/
|
||||||
public final class AndroidSpellCheckerService extends SpellCheckerService
|
public final class AndroidSpellCheckerService extends SpellCheckerService
|
||||||
implements SharedPreferences.OnSharedPreferenceChangeListener {
|
implements SharedPreferences.OnSharedPreferenceChangeListener {
|
||||||
|
private static final String TAG = AndroidSpellCheckerService.class.getSimpleName();
|
||||||
|
private static final boolean DEBUG = false;
|
||||||
|
|
||||||
public static final String PREF_USE_CONTACTS_KEY = "pref_spellcheck_use_contacts";
|
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_WIDTH = 480;
|
||||||
|
@ -80,6 +84,7 @@ public final class AndroidSpellCheckerService extends SpellCheckerService
|
||||||
|
|
||||||
public static final String SINGLE_QUOTE = "\u0027";
|
public static final String SINGLE_QUOTE = "\u0027";
|
||||||
public static final String APOSTROPHE = "\u2019";
|
public static final String APOSTROPHE = "\u2019";
|
||||||
|
private UserDictionaryLookup mUserDictionaryLookup;
|
||||||
|
|
||||||
public AndroidSpellCheckerService() {
|
public AndroidSpellCheckerService() {
|
||||||
super();
|
super();
|
||||||
|
@ -95,6 +100,24 @@ public final class AndroidSpellCheckerService extends SpellCheckerService
|
||||||
final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
|
final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
|
||||||
prefs.registerOnSharedPreferenceChangeListener(this);
|
prefs.registerOnSharedPreferenceChangeListener(this);
|
||||||
onSharedPreferenceChanged(prefs, PREF_USE_CONTACTS_KEY);
|
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() {
|
public float getRecommendedThreshold() {
|
||||||
|
@ -150,6 +173,16 @@ public final class AndroidSpellCheckerService extends SpellCheckerService
|
||||||
public boolean isValidWord(final Locale locale, final String word) {
|
public boolean isValidWord(final Locale locale, final String word) {
|
||||||
mSemaphore.acquireUninterruptibly();
|
mSemaphore.acquireUninterruptibly();
|
||||||
try {
|
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 =
|
DictionaryFacilitator dictionaryFacilitatorForLocale =
|
||||||
mDictionaryFacilitatorCache.get(locale);
|
mDictionaryFacilitatorCache.get(locale);
|
||||||
return dictionaryFacilitatorForLocale.isValidSpellingWord(word);
|
return dictionaryFacilitatorForLocale.isValidSpellingWord(word);
|
||||||
|
|
|
@ -0,0 +1,430 @@
|
||||||
|
/*
|
||||||
|
* 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 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.Executors;
|
||||||
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executor on which to perform the initial load and subsequent reloads (after a delay).
|
||||||
|
*/
|
||||||
|
private final ScheduledExecutorService mLoadExecutor =
|
||||||
|
Executors.newSingleThreadScheduledExecutor();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 = mLoadExecutor.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.
|
||||||
|
mLoadExecutor.schedule(mLoader, 0, TimeUnit.MILLISECONDS);
|
||||||
|
|
||||||
|
// 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)) {
|
||||||
|
// Shut down the load executor.
|
||||||
|
mLoadExecutor.shutdown();
|
||||||
|
|
||||||
|
// 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<String, ArrayList<Locale>>();
|
||||||
|
// 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<Locale>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,279 @@
|
||||||
|
/*
|
||||||
|
* 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<Uri> mAddedBackup;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void setUp() throws Exception {
|
||||||
|
super.setUp();
|
||||||
|
mContentResolver = mContext.getContentResolver();
|
||||||
|
mAddedBackup = new HashSet<Uri>();
|
||||||
|
}
|
||||||
|
|
||||||
|
@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));
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue