Home | History | Annotate | Download | only in latin
      1 /*
      2  * Copyright (C) 2015 The Android Open Source Project
      3  *
      4  * Licensed under the Apache License, Version 2.0 (the "License");
      5  * you may not use this file except in compliance with the License.
      6  * You may obtain a copy of the License at
      7  *
      8  *      http://www.apache.org/licenses/LICENSE-2.0
      9  *
     10  * Unless required by applicable law or agreed to in writing, software
     11  * distributed under the License is distributed on an "AS IS" BASIS,
     12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     13  * See the License for the specific language governing permissions and
     14  * limitations under the License.
     15  */
     16 
     17 package com.android.inputmethod.latin;
     18 
     19 import android.content.ContentResolver;
     20 import android.content.Context;
     21 import android.database.ContentObserver;
     22 import android.database.Cursor;
     23 import android.net.Uri;
     24 import android.provider.UserDictionary;
     25 import android.text.TextUtils;
     26 import android.util.Log;
     27 
     28 import com.android.inputmethod.annotations.UsedForTesting;
     29 import com.android.inputmethod.latin.common.CollectionUtils;
     30 import com.android.inputmethod.latin.common.LocaleUtils;
     31 import com.android.inputmethod.latin.define.DebugFlags;
     32 import com.android.inputmethod.latin.utils.ExecutorUtils;
     33 
     34 import java.io.Closeable;
     35 import java.util.ArrayList;
     36 import java.util.Collections;
     37 import java.util.HashMap;
     38 import java.util.HashSet;
     39 import java.util.List;
     40 import java.util.Locale;
     41 import java.util.Map;
     42 import java.util.Set;
     43 import java.util.concurrent.ScheduledFuture;
     44 import java.util.concurrent.TimeUnit;
     45 import java.util.concurrent.atomic.AtomicBoolean;
     46 
     47 import javax.annotation.Nonnull;
     48 import javax.annotation.Nullable;
     49 
     50 /**
     51  * This class provides the ability to look into the system-wide "Personal dictionary". It loads the
     52  * data once when created and reloads it when notified of changes to {@link UserDictionary}
     53  *
     54  * It can be used directly to validate words or expand shortcuts, and it can be used by instances
     55  * of {@link PersonalLanguageModelHelper} that create language model files for a specific input
     56  * locale.
     57  *
     58  * Note, that the initial dictionary loading happens asynchronously so it is possible (hopefully
     59  * rarely) that {@link #isValidWord} or {@link #expandShortcut} is called before the initial load
     60  * has started.
     61  *
     62  * The caller should explicitly call {@link #close} when the object is no longer needed, in order
     63  * to release any resources and references to this object.  A service should create this object in
     64  * {@link android.app.Service#onCreate} and close it in {@link android.app.Service#onDestroy}.
     65  */
     66 public class PersonalDictionaryLookup implements Closeable {
     67 
     68     /**
     69      * To avoid loading too many dictionary entries in memory, we cap them at this number.  If
     70      * that number is exceeded, the lowest-frequency items will be dropped.  Note, there is no
     71      * explicit cap on the number of locales in every entry.
     72      */
     73     private static final int MAX_NUM_ENTRIES = 1000;
     74 
     75     /**
     76      * The delay (in milliseconds) to impose on reloads.  Previously scheduled reloads will be
     77      * cancelled if a new reload is scheduled before the delay expires.  Thus, only the last
     78      * reload in the series of frequent reloads will execute.
     79      *
     80      * Note, this value should be low enough to allow the "Add to dictionary" feature in the
     81      * TextView correction (red underline) drop-down menu to work properly in the following case:
     82      *
     83      *   1. User types OOV (out-of-vocabulary) word.
     84      *   2. The OOV is red-underlined.
     85      *   3. User selects "Add to dictionary".  The red underline disappears while the OOV is
     86      *      in a composing span.
     87      *   4. The user taps space.  The red underline should NOT reappear.  If this value is very
     88      *      high and the user performs the space tap fast enough, the red underline may reappear.
     89      */
     90     @UsedForTesting
     91     static final int RELOAD_DELAY_MS = 200;
     92 
     93     @UsedForTesting
     94     static final Locale ANY_LOCALE = new Locale("");
     95 
     96     private final String mTag;
     97     private final ContentResolver mResolver;
     98     private final String mServiceName;
     99 
    100     /**
    101      * Interface to implement for classes interested in getting notified of updates.
    102      */
    103     public static interface PersonalDictionaryListener {
    104         public void onUpdate();
    105     }
    106 
    107     private final Set<PersonalDictionaryListener> mListeners = new HashSet<>();
    108 
    109     public void addListener(@Nonnull final PersonalDictionaryListener listener) {
    110         mListeners.add(listener);
    111     }
    112 
    113     public void removeListener(@Nonnull final PersonalDictionaryListener listener) {
    114         mListeners.remove(listener);
    115     }
    116 
    117     /**
    118      * Broadcast the update to all the Locale-specific language models.
    119      */
    120     @UsedForTesting
    121     void notifyListeners() {
    122         for (PersonalDictionaryListener listener : mListeners) {
    123             listener.onUpdate();
    124         }
    125     }
    126 
    127     /**
    128      *  Content observer for changes to the personal dictionary. It has the following properties:
    129      *    1. It spawns off a reload in another thread, after some delay.
    130      *    2. It cancels previously scheduled reloads, and only executes the latest.
    131      *    3. It may be called multiple times quickly in succession (and is in fact called so
    132      *       when the dictionary is edited through its settings UI, when sometimes multiple
    133      *       notifications are sent for the edited entry, but also for the entire dictionary).
    134      */
    135     private class PersonalDictionaryContentObserver extends ContentObserver implements Runnable {
    136         public PersonalDictionaryContentObserver() {
    137             super(null);
    138         }
    139 
    140         @Override
    141         public boolean deliverSelfNotifications() {
    142             return true;
    143         }
    144 
    145         // Support pre-API16 platforms.
    146         @Override
    147         public void onChange(boolean selfChange) {
    148             onChange(selfChange, null);
    149         }
    150 
    151         @Override
    152         public void onChange(boolean selfChange, Uri uri) {
    153             if (DebugFlags.DEBUG_ENABLED) {
    154                 Log.d(mTag, "onChange() : URI = " + uri);
    155             }
    156             // Cancel (but don't interrupt) any pending reloads (except the initial load).
    157             if (mReloadFuture != null && !mReloadFuture.isCancelled() &&
    158                     !mReloadFuture.isDone()) {
    159                 // Note, that if already cancelled or done, this will do nothing.
    160                 boolean isCancelled = mReloadFuture.cancel(false);
    161                 if (DebugFlags.DEBUG_ENABLED) {
    162                     if (isCancelled) {
    163                         Log.d(mTag, "onChange() : Canceled previous reload request");
    164                     } else {
    165                         Log.d(mTag, "onChange() : Failed to cancel previous reload request");
    166                     }
    167                 }
    168             }
    169 
    170             if (DebugFlags.DEBUG_ENABLED) {
    171                 Log.d(mTag, "onChange() : Scheduling reload in " + RELOAD_DELAY_MS + " ms");
    172             }
    173 
    174             // Schedule a new reload after RELOAD_DELAY_MS.
    175             mReloadFuture = ExecutorUtils.getBackgroundExecutor(mServiceName)
    176                     .schedule(this, RELOAD_DELAY_MS, TimeUnit.MILLISECONDS);
    177         }
    178 
    179         @Override
    180         public void run() {
    181             loadPersonalDictionary();
    182         }
    183     }
    184 
    185     private final PersonalDictionaryContentObserver mPersonalDictionaryContentObserver =
    186             new PersonalDictionaryContentObserver();
    187 
    188     /**
    189      * Indicates that a load is in progress, so no need for another.
    190      */
    191     private AtomicBoolean mIsLoading = new AtomicBoolean(false);
    192 
    193     /**
    194      * Indicates that this lookup object has been close()d.
    195      */
    196     private AtomicBoolean mIsClosed = new AtomicBoolean(false);
    197 
    198     /**
    199      * We store a map from a dictionary word to the set of locales & raw string(as it appears)
    200      * We then iterate over the set of locales to find a match using LocaleUtils.
    201      */
    202     private volatile HashMap<String, HashMap<Locale, String>> mDictWords;
    203 
    204     /**
    205      * We store a map from a shortcut to a word for each locale.
    206      * Shortcuts that apply to any locale are keyed by {@link #ANY_LOCALE}.
    207      */
    208     private volatile HashMap<Locale, HashMap<String, String>> mShortcutsPerLocale;
    209 
    210     /**
    211      *  The last-scheduled reload future.  Saved in order to cancel a pending reload if a new one
    212      * is coming.
    213      */
    214     private volatile ScheduledFuture<?> mReloadFuture;
    215 
    216     private volatile List<DictionaryStats> mDictionaryStats;
    217 
    218     /**
    219      * @param context the context from which to obtain content resolver
    220      */
    221     public PersonalDictionaryLookup(
    222             @Nonnull final Context context,
    223             @Nonnull final String serviceName) {
    224         mTag = serviceName + ".Personal";
    225 
    226         Log.i(mTag, "create()");
    227 
    228         mServiceName = serviceName;
    229         mDictionaryStats = new ArrayList<DictionaryStats>();
    230         mDictionaryStats.add(new DictionaryStats(ANY_LOCALE, Dictionary.TYPE_USER, 0));
    231         mDictionaryStats.add(new DictionaryStats(ANY_LOCALE, Dictionary.TYPE_USER_SHORTCUT, 0));
    232 
    233         // Obtain a content resolver.
    234         mResolver = context.getContentResolver();
    235     }
    236 
    237     public List<DictionaryStats> getDictionaryStats() {
    238         return mDictionaryStats;
    239     }
    240 
    241     public void open() {
    242         Log.i(mTag, "open()");
    243 
    244         // Schedule the initial load to run immediately.  It's possible that the first call to
    245         // isValidWord occurs before the dictionary has actually loaded, so it should not
    246         // assume that the dictionary has been loaded.
    247         loadPersonalDictionary();
    248 
    249         // Register the observer to be notified on changes to the personal dictionary and all
    250         // individual items.
    251         //
    252         // If the user is interacting with the Personal Dictionary settings UI, or with the
    253         // "Add to dictionary" drop-down option, duplicate notifications will be sent for the same
    254         // edit: if a new entry is added, there is a notification for the entry itself, and
    255         // separately for the entire dictionary. However, when used programmatically,
    256         // only notifications for the specific edits are sent. Thus, the observer is registered to
    257         // receive every possible notification, and instead has throttling logic to avoid doing too
    258         // many reloads.
    259         mResolver.registerContentObserver(
    260                 UserDictionary.Words.CONTENT_URI,
    261                 true /* notifyForDescendents */,
    262                 mPersonalDictionaryContentObserver);
    263     }
    264 
    265     /**
    266      * To be called by the garbage collector in the off chance that the service did not clean up
    267      * properly.  Do not rely on this getting called, and make sure close() is called explicitly.
    268      */
    269     @Override
    270     public void finalize() throws Throwable {
    271         try {
    272             if (DebugFlags.DEBUG_ENABLED) {
    273                 Log.d(mTag, "finalize()");
    274             }
    275             close();
    276         } finally {
    277             super.finalize();
    278         }
    279     }
    280 
    281     /**
    282      * Cleans up PersonalDictionaryLookup: shuts down any extra threads and unregisters the observer.
    283      *
    284      * It is safe, but not advised to call this multiple times, and isValidWord would continue to
    285      * work, but no data will be reloaded any longer.
    286      */
    287     @Override
    288     public void close() {
    289         if (DebugFlags.DEBUG_ENABLED) {
    290             Log.d(mTag, "close() : Unregistering content observer");
    291         }
    292         if (mIsClosed.compareAndSet(false, true)) {
    293             // Unregister the content observer.
    294             mResolver.unregisterContentObserver(mPersonalDictionaryContentObserver);
    295         }
    296     }
    297 
    298     /**
    299      * Returns true if the initial load has been performed.
    300      *
    301      * @return true if the initial load is successful
    302      */
    303     public boolean isLoaded() {
    304         return mDictWords != null && mShortcutsPerLocale != null;
    305     }
    306 
    307     /**
    308      * Returns the set of words defined for the given locale and more general locales.
    309      *
    310      * For example, input locale en_US uses data for en_US, en, and the global dictionary.
    311      *
    312      * Note that this method returns expanded words, not shortcuts. Shortcuts are handled
    313      * by {@link #getShortcutsForLocale}.
    314      *
    315      * @param inputLocale the locale to restrict for
    316      * @return set of words that apply to the given locale.
    317      */
    318     public Set<String> getWordsForLocale(@Nonnull final Locale inputLocale) {
    319         final HashMap<String, HashMap<Locale, String>> dictWords = mDictWords;
    320         if (CollectionUtils.isNullOrEmpty(dictWords)) {
    321             return Collections.emptySet();
    322         }
    323 
    324         final Set<String> words = new HashSet<>();
    325         final String inputLocaleString = inputLocale.toString();
    326         for (String word : dictWords.keySet()) {
    327             HashMap<Locale, String> localeStringMap = dictWords.get(word);
    328                 if (!CollectionUtils.isNullOrEmpty(localeStringMap)) {
    329                     for (Locale wordLocale : localeStringMap.keySet()) {
    330                         final String wordLocaleString = wordLocale.toString();
    331                         final int match = LocaleUtils.getMatchLevel(wordLocaleString, inputLocaleString);
    332                         if (LocaleUtils.isMatch(match)) {
    333                             words.add(localeStringMap.get(wordLocale));
    334                         }
    335                     }
    336             }
    337         }
    338         return words;
    339     }
    340 
    341     /**
    342      * Returns the set of shortcuts defined for the given locale and more general locales.
    343      *
    344      * For example, input locale en_US uses data for en_US, en, and the global dictionary.
    345      *
    346      * Note that this method returns shortcut keys, not expanded words. Words are handled
    347      * by {@link #getWordsForLocale}.
    348      *
    349      * @param inputLocale the locale to restrict for
    350      * @return set of shortcuts that apply to the given locale.
    351      */
    352     public Set<String> getShortcutsForLocale(@Nonnull final Locale inputLocale) {
    353         final Map<Locale, HashMap<String, String>> shortcutsPerLocale = mShortcutsPerLocale;
    354         if (CollectionUtils.isNullOrEmpty(shortcutsPerLocale)) {
    355             return Collections.emptySet();
    356         }
    357 
    358         final Set<String> shortcuts = new HashSet<>();
    359         if (!TextUtils.isEmpty(inputLocale.getCountry())) {
    360             // First look for the country-specific shortcut: en_US, en_UK, fr_FR, etc.
    361             final Map<String, String> countryShortcuts = shortcutsPerLocale.get(inputLocale);
    362             if (!CollectionUtils.isNullOrEmpty(countryShortcuts)) {
    363                 shortcuts.addAll(countryShortcuts.keySet());
    364             }
    365         }
    366 
    367         // Next look for the language-specific shortcut: en, fr, etc.
    368         final Locale languageOnlyLocale =
    369                 LocaleUtils.constructLocaleFromString(inputLocale.getLanguage());
    370         final Map<String, String> languageShortcuts = shortcutsPerLocale.get(languageOnlyLocale);
    371         if (!CollectionUtils.isNullOrEmpty(languageShortcuts)) {
    372             shortcuts.addAll(languageShortcuts.keySet());
    373         }
    374 
    375         // If all else fails, look for a global shortcut.
    376         final Map<String, String> globalShortcuts = shortcutsPerLocale.get(ANY_LOCALE);
    377         if (!CollectionUtils.isNullOrEmpty(globalShortcuts)) {
    378             shortcuts.addAll(globalShortcuts.keySet());
    379         }
    380 
    381         return shortcuts;
    382     }
    383 
    384     /**
    385      * Determines if the given word is a valid word in the given locale based on the dictionary.
    386      * It tries hard to find a match: for example, casing is ignored and if the word is present in a
    387      * more general locale (e.g. en or all locales), and isValidWord is asking for a more specific
    388      * locale (e.g. en_US), it will be considered a match.
    389      *
    390      * @param word the word to match
    391      * @param inputLocale the locale in which to match the word
    392      * @return true iff the word has been matched for this locale in the dictionary.
    393      */
    394     public boolean isValidWord(@Nonnull final String word, @Nonnull final Locale inputLocale) {
    395         if (!isLoaded()) {
    396             // This is a corner case in the event the initial load of the dictionary has not
    397             // completed. In that case, we assume the word is not a valid word in the dictionary.
    398             if (DebugFlags.DEBUG_ENABLED) {
    399                 Log.d(mTag, "isValidWord() : Initial load not complete");
    400             }
    401             return false;
    402         }
    403 
    404         if (DebugFlags.DEBUG_ENABLED) {
    405             Log.d(mTag, "isValidWord() : Word [" + word + "] in Locale [" + inputLocale + "]");
    406         }
    407         // Atomically obtain the current copy of mDictWords;
    408         final HashMap<String, HashMap<Locale, String>> dictWords = mDictWords;
    409         // Lowercase the word using the given locale. Note, that dictionary
    410         // words are lowercased using their locale, and theoretically the
    411         // lowercasing between two matching locales may differ. For simplicity
    412         // we ignore that possibility.
    413         final String lowercased = word.toLowerCase(inputLocale);
    414         final HashMap<Locale, String> dictLocales = dictWords.get(lowercased);
    415 
    416         if (CollectionUtils.isNullOrEmpty(dictLocales)) {
    417             if (DebugFlags.DEBUG_ENABLED) {
    418                 Log.d(mTag, "isValidWord() : No entry for word [" + word + "]");
    419             }
    420             return false;
    421         } else {
    422             if (DebugFlags.DEBUG_ENABLED) {
    423                 Log.d(mTag, "isValidWord() : Found entry for word [" + word + "]");
    424             }
    425             // Iterate over the locales this word is in.
    426             for (final Locale dictLocale : dictLocales.keySet()) {
    427                 final int matchLevel = LocaleUtils.getMatchLevel(dictLocale.toString(),
    428                         inputLocale.toString());
    429                 if (DebugFlags.DEBUG_ENABLED) {
    430                     Log.d(mTag, "isValidWord() : MatchLevel for DictLocale [" + dictLocale
    431                             + "] and InputLocale [" + inputLocale + "] is " + matchLevel);
    432                 }
    433                 if (LocaleUtils.isMatch(matchLevel)) {
    434                     if (DebugFlags.DEBUG_ENABLED) {
    435                         Log.d(mTag, "isValidWord() : MatchLevel " + matchLevel + " IS a match");
    436                     }
    437                     return true;
    438                 }
    439                 if (DebugFlags.DEBUG_ENABLED) {
    440                     Log.d(mTag, "isValidWord() : MatchLevel " + matchLevel + " is NOT a match");
    441                 }
    442             }
    443             if (DebugFlags.DEBUG_ENABLED) {
    444                 Log.d(mTag, "isValidWord() : False, since none of the locales matched");
    445             }
    446             return false;
    447         }
    448     }
    449 
    450     /**
    451      * Expands the given shortcut for the given locale.
    452      *
    453      * @param shortcut the shortcut to expand
    454      * @param inputLocale the locale in which to expand the shortcut
    455      * @return expanded shortcut iff the word is a shortcut in the dictionary.
    456      */
    457     @Nullable public String expandShortcut(
    458             @Nonnull final String shortcut, @Nonnull final Locale inputLocale) {
    459         if (DebugFlags.DEBUG_ENABLED) {
    460             Log.d(mTag, "expandShortcut() : Shortcut [" + shortcut + "] for [" + inputLocale + "]");
    461         }
    462 
    463         // Atomically obtain the current copy of mShortcuts;
    464         final HashMap<Locale, HashMap<String, String>> shortcutsPerLocale = mShortcutsPerLocale;
    465 
    466         // Exit as early as possible. Most users don't use shortcuts.
    467         if (CollectionUtils.isNullOrEmpty(shortcutsPerLocale)) {
    468             if (DebugFlags.DEBUG_ENABLED) {
    469                 Log.d(mTag, "expandShortcut() : User has no shortcuts");
    470             }
    471             return null;
    472         }
    473 
    474         if (!TextUtils.isEmpty(inputLocale.getCountry())) {
    475             // First look for the country-specific shortcut: en_US, en_UK, fr_FR, etc.
    476             final String expansionForCountry = expandShortcut(
    477                     shortcutsPerLocale, shortcut, inputLocale);
    478             if (!TextUtils.isEmpty(expansionForCountry)) {
    479                 if (DebugFlags.DEBUG_ENABLED) {
    480                     Log.d(mTag, "expandShortcut() : Country expansion is ["
    481                             + expansionForCountry + "]");
    482                 }
    483                 return expansionForCountry;
    484             }
    485         }
    486 
    487         // Next look for the language-specific shortcut: en, fr, etc.
    488         final Locale languageOnlyLocale =
    489                 LocaleUtils.constructLocaleFromString(inputLocale.getLanguage());
    490         final String expansionForLanguage = expandShortcut(
    491                 shortcutsPerLocale, shortcut, languageOnlyLocale);
    492         if (!TextUtils.isEmpty(expansionForLanguage)) {
    493             if (DebugFlags.DEBUG_ENABLED) {
    494                 Log.d(mTag, "expandShortcut() : Language expansion is ["
    495                         + expansionForLanguage + "]");
    496             }
    497             return expansionForLanguage;
    498         }
    499 
    500         // If all else fails, look for a global shortcut.
    501         final String expansionForGlobal = expandShortcut(shortcutsPerLocale, shortcut, ANY_LOCALE);
    502         if (!TextUtils.isEmpty(expansionForGlobal) && DebugFlags.DEBUG_ENABLED) {
    503             Log.d(mTag, "expandShortcut() : Global expansion is [" + expansionForGlobal + "]");
    504         }
    505         return expansionForGlobal;
    506     }
    507 
    508     @Nullable private String expandShortcut(
    509             @Nullable final HashMap<Locale, HashMap<String, String>> shortcutsPerLocale,
    510             @Nonnull final String shortcut,
    511             @Nonnull final Locale locale) {
    512         if (CollectionUtils.isNullOrEmpty(shortcutsPerLocale)) {
    513             return null;
    514         }
    515         final HashMap<String, String> localeShortcuts = shortcutsPerLocale.get(locale);
    516         if (CollectionUtils.isNullOrEmpty(localeShortcuts)) {
    517             return null;
    518         }
    519         return localeShortcuts.get(shortcut);
    520     }
    521 
    522     /**
    523      * Loads the personal dictionary in the current thread.
    524      *
    525      * Only one reload can happen at a time. If already running, will exit quickly.
    526      */
    527     private void loadPersonalDictionary() {
    528         // Bail out if already in the process of loading.
    529         if (!mIsLoading.compareAndSet(false, true)) {
    530             Log.i(mTag, "loadPersonalDictionary() : Already Loading (exit)");
    531             return;
    532         }
    533         Log.i(mTag, "loadPersonalDictionary() : Start Loading");
    534         HashMap<String, HashMap<Locale, String>> dictWords = new HashMap<>();
    535         HashMap<Locale, HashMap<String, String>> shortcutsPerLocale = new HashMap<>();
    536         // Load the dictionary.  Items are returned in the default sort order (by frequency).
    537         Cursor cursor = mResolver.query(UserDictionary.Words.CONTENT_URI,
    538                 null, null, null, UserDictionary.Words.DEFAULT_SORT_ORDER);
    539         if (null == cursor || cursor.getCount() < 1) {
    540             Log.i(mTag, "loadPersonalDictionary() : Empty");
    541         } else {
    542             // Iterate over the entries in the personal dictionary.  Note, that iteration is in
    543             // descending frequency by default.
    544             while (dictWords.size() < MAX_NUM_ENTRIES && cursor.moveToNext()) {
    545                 // If there is no column for locale, skip this entry. An empty
    546                 // locale on the other hand will not be skipped.
    547                 final int dictLocaleIndex = cursor.getColumnIndex(UserDictionary.Words.LOCALE);
    548                 if (dictLocaleIndex < 0) {
    549                     if (DebugFlags.DEBUG_ENABLED) {
    550                         Log.d(mTag, "loadPersonalDictionary() : Entry without LOCALE, skipping");
    551                     }
    552                     continue;
    553                 }
    554                 // If there is no column for word, skip this entry.
    555                 final int dictWordIndex = cursor.getColumnIndex(UserDictionary.Words.WORD);
    556                 if (dictWordIndex < 0) {
    557                     if (DebugFlags.DEBUG_ENABLED) {
    558                         Log.d(mTag, "loadPersonalDictionary() : Entry without WORD, skipping");
    559                     }
    560                     continue;
    561                 }
    562                 // If the word is null, skip this entry.
    563                 final String rawDictWord = cursor.getString(dictWordIndex);
    564                 if (null == rawDictWord) {
    565                     if (DebugFlags.DEBUG_ENABLED) {
    566                         Log.d(mTag, "loadPersonalDictionary() : Null word");
    567                     }
    568                     continue;
    569                 }
    570                 // If the locale is null, that's interpreted to mean all locales. Note, the special
    571                 // zz locale for an Alphabet (QWERTY) layout will not match any actual language.
    572                 String localeString = cursor.getString(dictLocaleIndex);
    573                 if (null == localeString) {
    574                     if (DebugFlags.DEBUG_ENABLED) {
    575                         Log.d(mTag, "loadPersonalDictionary() : Null locale for word [" +
    576                                 rawDictWord + "], assuming all locales");
    577                     }
    578                     // For purposes of LocaleUtils, an empty locale matches everything.
    579                     localeString = "";
    580                 }
    581                 final Locale dictLocale = LocaleUtils.constructLocaleFromString(localeString);
    582                 // Lowercase the word before storing it.
    583                 final String dictWord = rawDictWord.toLowerCase(dictLocale);
    584                 if (DebugFlags.DEBUG_ENABLED) {
    585                     Log.d(mTag, "loadPersonalDictionary() : Adding word [" + dictWord
    586                             + "] for locale " + dictLocale + "with value" + rawDictWord);
    587                 }
    588                 // Check if there is an existing entry for this word.
    589                 HashMap<Locale, String> dictLocales = dictWords.get(dictWord);
    590                 if (CollectionUtils.isNullOrEmpty(dictLocales)) {
    591                     // If there is no entry for this word, create one.
    592                     if (DebugFlags.DEBUG_ENABLED) {
    593                         Log.d(mTag, "loadPersonalDictionary() : Word [" + dictWord +
    594                                 "] not seen for other locales, creating new entry");
    595                     }
    596                     dictLocales = new HashMap<>();
    597                     dictWords.put(dictWord, dictLocales);
    598                 }
    599                 // Append the locale to the list of locales this word is in.
    600                 dictLocales.put(dictLocale, rawDictWord);
    601 
    602                 // If there is no column for a shortcut, we're done.
    603                 final int shortcutIndex = cursor.getColumnIndex(UserDictionary.Words.SHORTCUT);
    604                 if (shortcutIndex < 0) {
    605                     if (DebugFlags.DEBUG_ENABLED) {
    606                         Log.d(mTag, "loadPersonalDictionary() : Entry without SHORTCUT, done");
    607                     }
    608                     continue;
    609                 }
    610                 // If the shortcut is null, we're done.
    611                 final String shortcut = cursor.getString(shortcutIndex);
    612                 if (shortcut == null) {
    613                     if (DebugFlags.DEBUG_ENABLED) {
    614                         Log.d(mTag, "loadPersonalDictionary() : Null shortcut");
    615                     }
    616                     continue;
    617                 }
    618                 // Else, save the shortcut.
    619                 HashMap<String, String> localeShortcuts = shortcutsPerLocale.get(dictLocale);
    620                 if (localeShortcuts == null) {
    621                     localeShortcuts = new HashMap<>();
    622                     shortcutsPerLocale.put(dictLocale, localeShortcuts);
    623                 }
    624                 // Map to the raw input, which might be capitalized.
    625                 // This lets the user create a shortcut from "gm" to "General Motors".
    626                 localeShortcuts.put(shortcut, rawDictWord);
    627             }
    628         }
    629 
    630         List<DictionaryStats> stats = new ArrayList<>();
    631         stats.add(new DictionaryStats(ANY_LOCALE, Dictionary.TYPE_USER, dictWords.size()));
    632         int numShortcuts = 0;
    633         for (HashMap<String, String> shortcuts : shortcutsPerLocale.values()) {
    634             numShortcuts += shortcuts.size();
    635         }
    636         stats.add(new DictionaryStats(ANY_LOCALE, Dictionary.TYPE_USER_SHORTCUT, numShortcuts));
    637         mDictionaryStats = stats;
    638 
    639         // Atomically replace the copy of mDictWords and mShortcuts.
    640         mDictWords = dictWords;
    641         mShortcutsPerLocale = shortcutsPerLocale;
    642 
    643         // Allow other calls to loadPersonalDictionary to execute now.
    644         mIsLoading.set(false);
    645 
    646         Log.i(mTag, "loadPersonalDictionary() : Loaded " + mDictWords.size()
    647                 + " words and " + numShortcuts + " shortcuts");
    648 
    649         notifyListeners();
    650     }
    651 }
    652