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
17package com.android.inputmethod.latin;
18
19import android.content.ContentResolver;
20import android.content.Context;
21import android.database.ContentObserver;
22import android.database.Cursor;
23import android.net.Uri;
24import android.provider.UserDictionary;
25import android.text.TextUtils;
26import android.util.Log;
27
28import com.android.inputmethod.annotations.UsedForTesting;
29import com.android.inputmethod.latin.common.CollectionUtils;
30import com.android.inputmethod.latin.common.LocaleUtils;
31import com.android.inputmethod.latin.define.DebugFlags;
32import com.android.inputmethod.latin.utils.ExecutorUtils;
33
34import java.io.Closeable;
35import java.util.ArrayList;
36import java.util.Collections;
37import java.util.HashMap;
38import java.util.HashSet;
39import java.util.List;
40import java.util.Locale;
41import java.util.Map;
42import java.util.Set;
43import java.util.concurrent.ScheduledFuture;
44import java.util.concurrent.TimeUnit;
45import java.util.concurrent.atomic.AtomicBoolean;
46
47import javax.annotation.Nonnull;
48import 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 */
66public 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