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 {
onUpdate()104         public void onUpdate();
105     }
106 
107     private final Set<PersonalDictionaryListener> mListeners = new HashSet<>();
108 
addListener(@onnull final PersonalDictionaryListener listener)109     public void addListener(@Nonnull final PersonalDictionaryListener listener) {
110         mListeners.add(listener);
111     }
112 
removeListener(@onnull final PersonalDictionaryListener listener)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
notifyListeners()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 {
PersonalDictionaryContentObserver()136         public PersonalDictionaryContentObserver() {
137             super(null);
138         }
139 
140         @Override
deliverSelfNotifications()141         public boolean deliverSelfNotifications() {
142             return true;
143         }
144 
145         // Support pre-API16 platforms.
146         @Override
onChange(boolean selfChange)147         public void onChange(boolean selfChange) {
148             onChange(selfChange, null);
149         }
150 
151         @Override
onChange(boolean selfChange, Uri uri)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
run()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      */
PersonalDictionaryLookup( @onnull final Context context, @Nonnull final String serviceName)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 
getDictionaryStats()237     public List<DictionaryStats> getDictionaryStats() {
238         return mDictionaryStats;
239     }
240 
open()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
finalize()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
close()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      */
isLoaded()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      */
getWordsForLocale(@onnull final Locale inputLocale)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      */
getShortcutsForLocale(@onnull final Locale inputLocale)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      */
isValidWord(@onnull final String word, @Nonnull final Locale inputLocale)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      */
expandShortcut( @onnull final String shortcut, @Nonnull final Locale inputLocale)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 
expandShortcut( @ullable final HashMap<Locale, HashMap<String, String>> shortcutsPerLocale, @Nonnull final String shortcut, @Nonnull final Locale locale)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      */
loadPersonalDictionary()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