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