| /* |
| * 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.define.DebugFlags; |
| import com.android.inputmethod.latin.utils.ExecutorUtils; |
| |
| import java.io.Closeable; |
| import java.util.ArrayList; |
| import java.util.Collections; |
| import java.util.HashMap; |
| import java.util.HashSet; |
| import java.util.List; |
| import java.util.Locale; |
| import java.util.Map; |
| import java.util.Set; |
| import java.util.concurrent.ScheduledFuture; |
| import java.util.concurrent.TimeUnit; |
| import java.util.concurrent.atomic.AtomicBoolean; |
| |
| import javax.annotation.Nonnull; |
| import javax.annotation.Nullable; |
| |
| /** |
| * This class provides the ability to look into the system-wide "Personal dictionary". It loads the |
| * data once when created and reloads it when notified of changes to {@link UserDictionary} |
| * |
| * It can be used directly to validate words or expand shortcuts, and it can be used by instances |
| * of {@link PersonalLanguageModelHelper} that create language model files for a specific input |
| * locale. |
| * |
| * Note, that the initial dictionary loading happens asynchronously so it is possible (hopefully |
| * rarely) that {@link #isValidWord} or {@link #expandShortcut} is called before the initial load |
| * has started. |
| * |
| * The caller should explicitly call {@link #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 |
| * {@link android.app.Service#onCreate} and close it in {@link android.app.Service#onDestroy}. |
| */ |
| public class PersonalDictionaryLookup implements Closeable { |
| |
| /** |
| * 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; |
| |
| /** |
| * Interface to implement for classes interested in getting notified of updates. |
| */ |
| public static interface PersonalDictionaryListener { |
| public void onUpdate(); |
| } |
| |
| private final Set<PersonalDictionaryListener> mListeners = new HashSet<>(); |
| |
| public void addListener(@Nonnull final PersonalDictionaryListener listener) { |
| mListeners.add(listener); |
| } |
| |
| public void removeListener(@Nonnull final PersonalDictionaryListener listener) { |
| mListeners.remove(listener); |
| } |
| |
| /** |
| * Broadcast the update to all the Locale-specific language models. |
| */ |
| @UsedForTesting |
| void notifyListeners() { |
| for (PersonalDictionaryListener listener : mListeners) { |
| listener.onUpdate(); |
| } |
| } |
| |
| /** |
| * Content observer for changes to the personal dictionary. It has the following properties: |
| * 1. It spawns off a 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 the dictionary is edited through its settings UI, when sometimes multiple |
| * notifications are sent for the edited entry, but also for the entire dictionary). |
| */ |
| private class PersonalDictionaryContentObserver extends ContentObserver implements Runnable { |
| public PersonalDictionaryContentObserver() { |
| 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 (DebugFlags.DEBUG_ENABLED) { |
| Log.d(mTag, "onChange() : 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 (DebugFlags.DEBUG_ENABLED) { |
| if (isCancelled) { |
| Log.d(mTag, "onChange() : Canceled previous reload request"); |
| } else { |
| Log.d(mTag, "onChange() : Failed to cancel previous reload request"); |
| } |
| } |
| } |
| |
| if (DebugFlags.DEBUG_ENABLED) { |
| Log.d(mTag, "onChange() : Scheduling reload in " + RELOAD_DELAY_MS + " ms"); |
| } |
| |
| // Schedule a new reload after RELOAD_DELAY_MS. |
| mReloadFuture = ExecutorUtils.getBackgroundExecutor(mServiceName) |
| .schedule(this, RELOAD_DELAY_MS, TimeUnit.MILLISECONDS); |
| } |
| |
| @Override |
| public void run() { |
| loadPersonalDictionary(); |
| } |
| } |
| |
| private final PersonalDictionaryContentObserver mPersonalDictionaryContentObserver = |
| new PersonalDictionaryContentObserver(); |
| |
| /** |
| * 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 & raw string(as it appears) |
| * We then iterate over the set of locales to find a match using LocaleUtils. |
| */ |
| private volatile HashMap<String, HashMap<Locale, String>> 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<Locale, HashMap<String, String>> mShortcutsPerLocale; |
| |
| /** |
| * The last-scheduled reload future. Saved in order to cancel a pending reload if a new one |
| * is coming. |
| */ |
| private volatile ScheduledFuture<?> mReloadFuture; |
| |
| private volatile List<DictionaryStats> mDictionaryStats; |
| |
| /** |
| * @param context the context from which to obtain content resolver |
| */ |
| public PersonalDictionaryLookup( |
| @Nonnull final Context context, |
| @Nonnull final String serviceName) { |
| mTag = serviceName + ".Personal"; |
| |
| Log.i(mTag, "create()"); |
| |
| mServiceName = serviceName; |
| mDictionaryStats = new ArrayList<DictionaryStats>(); |
| mDictionaryStats.add(new DictionaryStats(ANY_LOCALE, Dictionary.TYPE_USER, 0)); |
| mDictionaryStats.add(new DictionaryStats(ANY_LOCALE, Dictionary.TYPE_USER_SHORTCUT, 0)); |
| |
| // Obtain a content resolver. |
| mResolver = context.getContentResolver(); |
| } |
| |
| public List<DictionaryStats> getDictionaryStats() { |
| return mDictionaryStats; |
| } |
| |
| 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. |
| loadPersonalDictionary(); |
| |
| // Register the observer to be notified on changes to the personal dictionary and all |
| // individual items. |
| // |
| // If the user is interacting with the Personal Dictionary 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 */, |
| mPersonalDictionaryContentObserver); |
| } |
| |
| /** |
| * 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 (DebugFlags.DEBUG_ENABLED) { |
| Log.d(mTag, "finalize()"); |
| } |
| close(); |
| } finally { |
| super.finalize(); |
| } |
| } |
| |
| /** |
| * Cleans up PersonalDictionaryLookup: 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 (DebugFlags.DEBUG_ENABLED) { |
| Log.d(mTag, "close() : Unregistering content observer"); |
| } |
| if (mIsClosed.compareAndSet(false, true)) { |
| // Unregister the content observer. |
| mResolver.unregisterContentObserver(mPersonalDictionaryContentObserver); |
| } |
| } |
| |
| /** |
| * 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; |
| } |
| |
| /** |
| * Returns the set of words defined for the given locale and more general locales. |
| * |
| * For example, input locale en_US uses data for en_US, en, and the global dictionary. |
| * |
| * Note that this method returns expanded words, not shortcuts. Shortcuts are handled |
| * by {@link #getShortcutsForLocale}. |
| * |
| * @param inputLocale the locale to restrict for |
| * @return set of words that apply to the given locale. |
| */ |
| public Set<String> getWordsForLocale(@Nonnull final Locale inputLocale) { |
| final HashMap<String, HashMap<Locale, String>> dictWords = mDictWords; |
| if (CollectionUtils.isNullOrEmpty(dictWords)) { |
| return Collections.emptySet(); |
| } |
| |
| final Set<String> words = new HashSet<>(); |
| final String inputLocaleString = inputLocale.toString(); |
| for (String word : dictWords.keySet()) { |
| HashMap<Locale, String> localeStringMap = dictWords.get(word); |
| if (!CollectionUtils.isNullOrEmpty(localeStringMap)) { |
| for (Locale wordLocale : localeStringMap.keySet()) { |
| final String wordLocaleString = wordLocale.toString(); |
| final int match = LocaleUtils.getMatchLevel(wordLocaleString, inputLocaleString); |
| if (LocaleUtils.isMatch(match)) { |
| words.add(localeStringMap.get(wordLocale)); |
| } |
| } |
| } |
| } |
| return words; |
| } |
| |
| /** |
| * Returns the set of shortcuts defined for the given locale and more general locales. |
| * |
| * For example, input locale en_US uses data for en_US, en, and the global dictionary. |
| * |
| * Note that this method returns shortcut keys, not expanded words. Words are handled |
| * by {@link #getWordsForLocale}. |
| * |
| * @param inputLocale the locale to restrict for |
| * @return set of shortcuts that apply to the given locale. |
| */ |
| public Set<String> getShortcutsForLocale(@Nonnull final Locale inputLocale) { |
| final Map<Locale, HashMap<String, String>> shortcutsPerLocale = mShortcutsPerLocale; |
| if (CollectionUtils.isNullOrEmpty(shortcutsPerLocale)) { |
| return Collections.emptySet(); |
| } |
| |
| final Set<String> shortcuts = new HashSet<>(); |
| if (!TextUtils.isEmpty(inputLocale.getCountry())) { |
| // First look for the country-specific shortcut: en_US, en_UK, fr_FR, etc. |
| final Map<String, String> countryShortcuts = shortcutsPerLocale.get(inputLocale); |
| if (!CollectionUtils.isNullOrEmpty(countryShortcuts)) { |
| shortcuts.addAll(countryShortcuts.keySet()); |
| } |
| } |
| |
| // Next look for the language-specific shortcut: en, fr, etc. |
| final Locale languageOnlyLocale = |
| LocaleUtils.constructLocaleFromString(inputLocale.getLanguage()); |
| final Map<String, String> languageShortcuts = shortcutsPerLocale.get(languageOnlyLocale); |
| if (!CollectionUtils.isNullOrEmpty(languageShortcuts)) { |
| shortcuts.addAll(languageShortcuts.keySet()); |
| } |
| |
| // If all else fails, look for a global shortcut. |
| final Map<String, String> globalShortcuts = shortcutsPerLocale.get(ANY_LOCALE); |
| if (!CollectionUtils.isNullOrEmpty(globalShortcuts)) { |
| shortcuts.addAll(globalShortcuts.keySet()); |
| } |
| |
| return shortcuts; |
| } |
| |
| /** |
| * Determines if the given word is a valid word in the given locale based on the dictionary. |
| * 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 inputLocale the locale in which to match the word |
| * @return true iff the word has been matched for this locale in the dictionary. |
| */ |
| public boolean isValidWord(@Nonnull final String word, @Nonnull final Locale inputLocale) { |
| if (!isLoaded()) { |
| // This is a corner case in the event the initial load of the dictionary has not |
| // completed. In that case, we assume the word is not a valid word in the dictionary. |
| if (DebugFlags.DEBUG_ENABLED) { |
| Log.d(mTag, "isValidWord() : Initial load not complete"); |
| } |
| return false; |
| } |
| |
| if (DebugFlags.DEBUG_ENABLED) { |
| Log.d(mTag, "isValidWord() : Word [" + word + "] in Locale [" + inputLocale + "]"); |
| } |
| // Atomically obtain the current copy of mDictWords; |
| final HashMap<String, HashMap<Locale, String>> dictWords = mDictWords; |
| // 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(inputLocale); |
| final HashMap<Locale, String> dictLocales = dictWords.get(lowercased); |
| |
| if (CollectionUtils.isNullOrEmpty(dictLocales)) { |
| if (DebugFlags.DEBUG_ENABLED) { |
| Log.d(mTag, "isValidWord() : No entry for word [" + word + "]"); |
| } |
| return false; |
| } else { |
| if (DebugFlags.DEBUG_ENABLED) { |
| Log.d(mTag, "isValidWord() : Found entry for word [" + word + "]"); |
| } |
| // Iterate over the locales this word is in. |
| for (final Locale dictLocale : dictLocales.keySet()) { |
| final int matchLevel = LocaleUtils.getMatchLevel(dictLocale.toString(), |
| inputLocale.toString()); |
| if (DebugFlags.DEBUG_ENABLED) { |
| Log.d(mTag, "isValidWord() : MatchLevel for DictLocale [" + dictLocale |
| + "] and InputLocale [" + inputLocale + "] is " + matchLevel); |
| } |
| if (LocaleUtils.isMatch(matchLevel)) { |
| if (DebugFlags.DEBUG_ENABLED) { |
| Log.d(mTag, "isValidWord() : MatchLevel " + matchLevel + " IS a match"); |
| } |
| return true; |
| } |
| if (DebugFlags.DEBUG_ENABLED) { |
| Log.d(mTag, "isValidWord() : MatchLevel " + matchLevel + " is NOT a match"); |
| } |
| } |
| if (DebugFlags.DEBUG_ENABLED) { |
| 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 dictionary. |
| */ |
| @Nullable public String expandShortcut( |
| @Nonnull final String shortcut, @Nonnull final Locale inputLocale) { |
| if (DebugFlags.DEBUG_ENABLED) { |
| Log.d(mTag, "expandShortcut() : Shortcut [" + shortcut + "] for [" + inputLocale + "]"); |
| } |
| |
| // Atomically obtain the current copy of mShortcuts; |
| final HashMap<Locale, HashMap<String, String>> shortcutsPerLocale = mShortcutsPerLocale; |
| |
| // Exit as early as possible. Most users don't use shortcuts. |
| if (CollectionUtils.isNullOrEmpty(shortcutsPerLocale)) { |
| if (DebugFlags.DEBUG_ENABLED) { |
| Log.d(mTag, "expandShortcut() : User has no shortcuts"); |
| } |
| 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)) { |
| if (DebugFlags.DEBUG_ENABLED) { |
| Log.d(mTag, "expandShortcut() : Country expansion is [" |
| + 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)) { |
| if (DebugFlags.DEBUG_ENABLED) { |
| Log.d(mTag, "expandShortcut() : Language expansion is [" |
| + expansionForLanguage + "]"); |
| } |
| return expansionForLanguage; |
| } |
| |
| // If all else fails, look for a global shortcut. |
| final String expansionForGlobal = expandShortcut(shortcutsPerLocale, shortcut, ANY_LOCALE); |
| if (!TextUtils.isEmpty(expansionForGlobal) && DebugFlags.DEBUG_ENABLED) { |
| Log.d(mTag, "expandShortcut() : Global expansion is [" + expansionForGlobal + "]"); |
| } |
| return expansionForGlobal; |
| } |
| |
| @Nullable private String expandShortcut( |
| @Nullable final HashMap<Locale, HashMap<String, String>> shortcutsPerLocale, |
| @Nonnull final String shortcut, |
| @Nonnull final Locale locale) { |
| if (CollectionUtils.isNullOrEmpty(shortcutsPerLocale)) { |
| return null; |
| } |
| final HashMap<String, String> localeShortcuts = shortcutsPerLocale.get(locale); |
| if (CollectionUtils.isNullOrEmpty(localeShortcuts)) { |
| return null; |
| } |
| return localeShortcuts.get(shortcut); |
| } |
| |
| /** |
| * Loads the personal dictionary in the current thread. |
| * |
| * Only one reload can happen at a time. If already running, will exit quickly. |
| */ |
| private void loadPersonalDictionary() { |
| // Bail out if already in the process of loading. |
| if (!mIsLoading.compareAndSet(false, true)) { |
| Log.i(mTag, "loadPersonalDictionary() : Already Loading (exit)"); |
| return; |
| } |
| Log.i(mTag, "loadPersonalDictionary() : Start Loading"); |
| HashMap<String, HashMap<Locale, String>> dictWords = new HashMap<>(); |
| HashMap<Locale, HashMap<String, String>> shortcutsPerLocale = new HashMap<>(); |
| // Load the dictionary. Items are returned in the default sort order (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, "loadPersonalDictionary() : Empty"); |
| } else { |
| // Iterate over the entries in the personal dictionary. 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 (DebugFlags.DEBUG_ENABLED) { |
| Log.d(mTag, "loadPersonalDictionary() : 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 (DebugFlags.DEBUG_ENABLED) { |
| Log.d(mTag, "loadPersonalDictionary() : Entry without WORD, skipping"); |
| } |
| continue; |
| } |
| // If the word is null, skip this entry. |
| final String rawDictWord = cursor.getString(dictWordIndex); |
| if (null == rawDictWord) { |
| if (DebugFlags.DEBUG_ENABLED) { |
| Log.d(mTag, "loadPersonalDictionary() : 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 (DebugFlags.DEBUG_ENABLED) { |
| Log.d(mTag, "loadPersonalDictionary() : 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 (DebugFlags.DEBUG_ENABLED) { |
| Log.d(mTag, "loadPersonalDictionary() : Adding word [" + dictWord |
| + "] for locale " + dictLocale + "with value" + rawDictWord); |
| } |
| // Check if there is an existing entry for this word. |
| HashMap<Locale, String> dictLocales = dictWords.get(dictWord); |
| if (CollectionUtils.isNullOrEmpty(dictLocales)) { |
| // If there is no entry for this word, create one. |
| if (DebugFlags.DEBUG_ENABLED) { |
| Log.d(mTag, "loadPersonalDictionary() : Word [" + dictWord + |
| "] not seen for other locales, creating new entry"); |
| } |
| dictLocales = new HashMap<>(); |
| dictWords.put(dictWord, dictLocales); |
| } |
| // Append the locale to the list of locales this word is in. |
| dictLocales.put(dictLocale, rawDictWord); |
| |
| // If there is no column for a shortcut, we're done. |
| final int shortcutIndex = cursor.getColumnIndex(UserDictionary.Words.SHORTCUT); |
| if (shortcutIndex < 0) { |
| if (DebugFlags.DEBUG_ENABLED) { |
| Log.d(mTag, "loadPersonalDictionary() : Entry without SHORTCUT, done"); |
| } |
| continue; |
| } |
| // If the shortcut is null, we're done. |
| final String shortcut = cursor.getString(shortcutIndex); |
| if (shortcut == null) { |
| if (DebugFlags.DEBUG_ENABLED) { |
| Log.d(mTag, "loadPersonalDictionary() : Null shortcut"); |
| } |
| continue; |
| } |
| // Else, save the shortcut. |
| HashMap<String, String> 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); |
| } |
| } |
| |
| List<DictionaryStats> stats = new ArrayList<>(); |
| stats.add(new DictionaryStats(ANY_LOCALE, Dictionary.TYPE_USER, dictWords.size())); |
| int numShortcuts = 0; |
| for (HashMap<String, String> shortcuts : shortcutsPerLocale.values()) { |
| numShortcuts += shortcuts.size(); |
| } |
| stats.add(new DictionaryStats(ANY_LOCALE, Dictionary.TYPE_USER_SHORTCUT, numShortcuts)); |
| mDictionaryStats = stats; |
| |
| // Atomically replace the copy of mDictWords and mShortcuts. |
| mDictWords = dictWords; |
| mShortcutsPerLocale = shortcutsPerLocale; |
| |
| // Allow other calls to loadPersonalDictionary to execute now. |
| mIsLoading.set(false); |
| |
| Log.i(mTag, "loadPersonalDictionary() : Loaded " + mDictWords.size() |
| + " words and " + numShortcuts + " shortcuts"); |
| |
| notifyListeners(); |
| } |
| } |