Make the DictionaryService stage the downloaded files

Bug: 20641948
Change-Id: I6639c995b12c033bc30241cd219201dd483ee516
diff --git a/common/src/com/android/inputmethod/latin/common/FileUtils.java b/common/src/com/android/inputmethod/latin/common/FileUtils.java
index 6768458..1673266 100644
--- a/common/src/com/android/inputmethod/latin/common/FileUtils.java
+++ b/common/src/com/android/inputmethod/latin/common/FileUtils.java
@@ -16,6 +16,8 @@
 
 package com.android.inputmethod.latin.common;
 
+import android.util.Log;
+
 import java.io.File;
 import java.io.FilenameFilter;
 
@@ -23,6 +25,8 @@
  * A simple class to help with removing directories recursively.
  */
 public class FileUtils {
+    private static final String TAG = "FileUtils";
+
     public static boolean deleteRecursively(final File path) {
         if (path.isDirectory()) {
             final File[] files = path.listFiles();
@@ -51,4 +55,14 @@
         }
         return hasDeletedAllFiles;
     }
+
+    public static boolean renameTo(final File fromFile, final File toFile) {
+        toFile.delete();
+        final boolean success = fromFile.renameTo(toFile);
+        if (!success) {
+            Log.e(TAG, String.format("Failed to rename from %s to %s.",
+                    fromFile.getAbsoluteFile(), toFile.getAbsoluteFile()));
+        }
+        return  success;
+    }
 }
diff --git a/java/src/com/android/inputmethod/dictionarypack/ActionBatch.java b/java/src/com/android/inputmethod/dictionarypack/ActionBatch.java
index 09f8032..ee5106b 100644
--- a/java/src/com/android/inputmethod/dictionarypack/ActionBatch.java
+++ b/java/src/com/android/inputmethod/dictionarypack/ActionBatch.java
@@ -26,7 +26,9 @@
 import android.util.Log;
 
 import com.android.inputmethod.compat.DownloadManagerCompatUtils;
+import com.android.inputmethod.latin.BinaryDictionaryFileDumper;
 import com.android.inputmethod.latin.R;
+import com.android.inputmethod.latin.common.LocaleUtils;
 import com.android.inputmethod.latin.utils.ApplicationUtils;
 import com.android.inputmethod.latin.utils.DebugLogUtils;
 
@@ -210,9 +212,17 @@
                         + " for an InstallAfterDownload action. Bailing out.");
                 return;
             }
+
             DebugLogUtils.l("Setting word list as installed");
             final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId);
             MetadataDbHelper.markEntryAsFinishedDownloadingAndInstalled(db, mWordListValues);
+
+            // Install the downloaded file by un-compressing and moving it to the staging
+            // directory. Ideally, we should do this before updating the DB, but the
+            // installDictToStagingFromContentProvider() relies on the db being updated.
+            final String localeString = mWordListValues.getAsString(MetadataDbHelper.LOCALE_COLUMN);
+            BinaryDictionaryFileDumper.installDictToStagingFromContentProvider(
+                    LocaleUtils.constructLocaleFromString(localeString), context, false);
         }
     }
 
diff --git a/java/src/com/android/inputmethod/dictionarypack/UpdateHandler.java b/java/src/com/android/inputmethod/dictionarypack/UpdateHandler.java
index e61547a..6570171 100644
--- a/java/src/com/android/inputmethod/dictionarypack/UpdateHandler.java
+++ b/java/src/com/android/inputmethod/dictionarypack/UpdateHandler.java
@@ -592,6 +592,8 @@
      * Warn Android Keyboard that the state of dictionaries changed and it should refresh its data.
      */
     private static void signalNewDictionaryState(final Context context) {
+        // TODO: Also provide the locale of the updated dictionary so that the LatinIme
+        // does not have to reset if it is a different locale.
         final Intent newDictBroadcast =
                 new Intent(DictionaryPackConstants.NEW_DICTIONARY_INTENT_ACTION);
         context.sendBroadcast(newDictBroadcast);
diff --git a/java/src/com/android/inputmethod/latin/BinaryDictionaryFileDumper.java b/java/src/com/android/inputmethod/latin/BinaryDictionaryFileDumper.java
index bc62f3a..4b242c5 100644
--- a/java/src/com/android/inputmethod/latin/BinaryDictionaryFileDumper.java
+++ b/java/src/com/android/inputmethod/latin/BinaryDictionaryFileDumper.java
@@ -29,6 +29,7 @@
 
 import com.android.inputmethod.dictionarypack.DictionaryPackConstants;
 import com.android.inputmethod.dictionarypack.MD5Calculator;
+import com.android.inputmethod.latin.common.FileUtils;
 import com.android.inputmethod.latin.define.DecoderSpecificConstants;
 import com.android.inputmethod.latin.utils.DictionaryInfoUtils;
 import com.android.inputmethod.latin.utils.DictionaryInfoUtils.DictionaryInfo;
@@ -220,11 +221,11 @@
     }
 
     /**
-     * Caches a word list the id of which is passed as an argument. This will write the file
+     * Stages a word list the id of which is passed as an argument. This will write the file
      * to the cache file name designated by its id and locale, overwriting it if already present
      * and creating it (and its containing directory) if necessary.
      */
-    private static void cacheWordList(final String wordlistId, final String locale,
+    private static void installWordListToStaging(final String wordlistId, final String locale,
             final String rawChecksum, final ContentProviderClient providerClient,
             final Context context) {
         final int COMPRESSED_CRYPTED_COMPRESSED = 0;
@@ -246,7 +247,7 @@
             return;
         }
         final String finalFileName =
-                DictionaryInfoUtils.getCacheFileName(wordlistId, locale, context);
+                DictionaryInfoUtils.getStagingFileName(wordlistId, locale, context);
         String tempFileName;
         try {
             tempFileName = BinaryDictionaryGetter.getTempFileName(wordlistId, context);
@@ -320,23 +321,21 @@
                     }
                 }
 
+                // move the output file to the final staging file.
                 final File finalFile = new File(finalFileName);
-                finalFile.delete();
-                if (!outputFile.renameTo(finalFile)) {
-                    throw new IOException("Can't move the file to its final name");
-                }
+                FileUtils.renameTo(outputFile, finalFile);
+
                 wordListUriBuilder.appendQueryParameter(QUERY_PARAMETER_DELETE_RESULT,
                         QUERY_PARAMETER_SUCCESS);
                 if (0 >= providerClient.delete(wordListUriBuilder.build(), null, null)) {
                     Log.e(TAG, "Could not have the dictionary pack delete a word list");
                 }
-                BinaryDictionaryGetter.removeFilesWithIdExcept(context, wordlistId, finalFile);
-                Log.e(TAG, "Successfully copied file for wordlist ID " + wordlistId);
+                Log.d(TAG, "Successfully copied file for wordlist ID " + wordlistId);
                 // Success! Close files (through the finally{} clause) and return.
                 return;
             } catch (Exception e) {
                 if (DEBUG) {
-                    Log.i(TAG, "Can't open word list in mode " + mode, e);
+                    Log.e(TAG, "Can't open word list in mode " + mode, e);
                 }
                 if (null != outputFile) {
                     // This may or may not fail. The file may not have been created if the
@@ -403,7 +402,7 @@
     }
 
     /**
-     * Queries a content provider for word list data for some locale and cache the returned files
+     * Queries a content provider for word list data for some locale and stage the returned files
      *
      * This will query a content provider for word list data for a given locale, and copy the
      * files locally so that they can be mmap'ed. This may overwrite previously cached word lists
@@ -411,7 +410,7 @@
      * @throw FileNotFoundException if the provider returns non-existent data.
      * @throw IOException if the provider-returned data could not be read.
      */
-    public static void cacheWordListsFromContentProvider(final Locale locale,
+    public static void installDictToStagingFromContentProvider(final Locale locale,
             final Context context, final boolean hasDefaultWordList) {
         final ContentProviderClient providerClient;
         try {
@@ -429,7 +428,8 @@
             final List<WordListInfo> idList = getWordListWordListInfos(locale, context,
                     hasDefaultWordList);
             for (WordListInfo id : idList) {
-                cacheWordList(id.mId, id.mLocale, id.mRawChecksum, providerClient, context);
+                installWordListToStaging(id.mId, id.mLocale, id.mRawChecksum, providerClient,
+                        context);
             }
         } finally {
             providerClient.release();
@@ -437,6 +437,19 @@
     }
 
     /**
+     * Downloads the dictionary if it was never requested/used.
+     *
+     * @param locale locale to download
+     * @param context the context for resources and providers.
+     * @param hasDefaultWordList whether the default wordlist exists in the resources.
+     */
+    public static void downloadDictIfNeverRequested(final Locale locale,
+            final Context context, final boolean hasDefaultWordList) {
+        Log.d("inamul_tag", "BinaryDictionaryFileDumper.downloadDictIfNeverRequested()");
+        getWordListWordListInfos(locale, context, hasDefaultWordList);
+    }
+
+    /**
      * Copies the data in an input stream to a target file if the magic number matches.
      *
      * If the magic number does not match the expected value, this method throws an
diff --git a/java/src/com/android/inputmethod/latin/BinaryDictionaryGetter.java b/java/src/com/android/inputmethod/latin/BinaryDictionaryGetter.java
index 5f2a112..6001637 100644
--- a/java/src/com/android/inputmethod/latin/BinaryDictionaryGetter.java
+++ b/java/src/com/android/inputmethod/latin/BinaryDictionaryGetter.java
@@ -195,39 +195,6 @@
         return result;
     }
 
-    /**
-     * Remove all files with the passed id, except the passed file.
-     *
-     * If a dictionary with a given ID has a metadata change that causes it to change
-     * path, we need to remove the old version. The only way to do this is to check all
-     * installed files for a matching ID in a different directory.
-     */
-    public static void removeFilesWithIdExcept(final Context context, final String id,
-            final File fileToKeep) {
-        try {
-            final File canonicalFileToKeep = fileToKeep.getCanonicalFile();
-            final File[] directoryList = DictionaryInfoUtils.getCachedDirectoryList(context);
-            if (null == directoryList) return;
-            for (File directory : directoryList) {
-                // There is one directory per locale. See #getCachedDirectoryList
-                if (!directory.isDirectory()) continue;
-                final File[] wordLists = directory.listFiles();
-                if (null == wordLists) continue;
-                for (File wordList : wordLists) {
-                    final String fileId =
-                            DictionaryInfoUtils.getWordListIdFromFileName(wordList.getName());
-                    if (fileId.equals(id)) {
-                        if (!canonicalFileToKeep.equals(wordList.getCanonicalFile())) {
-                            wordList.delete();
-                        }
-                    }
-                }
-            }
-        } catch (java.io.IOException e) {
-            Log.e(TAG, "IOException trying to cleanup files", e);
-        }
-    }
-
     // ## HACK ## we prevent usage of a dictionary before version 18. The reason for this is, since
     // those do not include whitelist entries, the new code with an old version of the dictionary
     // would lose whitelist functionality.
@@ -274,12 +241,18 @@
      */
     public static ArrayList<AssetFileAddress> getDictionaryFiles(final Locale locale,
             final Context context, boolean notifyDictionaryPackForUpdates) {
-
-        final boolean hasDefaultWordList = DictionaryInfoUtils.isDictionaryAvailable(
-                context, locale);
         if (notifyDictionaryPackForUpdates) {
-            BinaryDictionaryFileDumper.cacheWordListsFromContentProvider(locale, context,
-                    hasDefaultWordList);
+            final boolean hasDefaultWordList = DictionaryInfoUtils.isDictionaryAvailable(
+                    context, locale);
+            // It makes sure that the first time keyboard comes up and the dictionaries are reset,
+            // the DB is populated with the appropriate values for each locale. Helps in downloading
+            // the dictionaries when the user enables and switches new languages before the
+            // DictionaryService runs.
+            BinaryDictionaryFileDumper.downloadDictIfNeverRequested(
+                    locale, context, hasDefaultWordList);
+
+            // Move a staging files to the cache ddirectories if any.
+            DictionaryInfoUtils.moveStagingFilesIfExists(context);
         }
         final File[] cachedWordLists = getCachedWordLists(locale.toString(), context);
         final String mainDictId = DictionaryInfoUtils.getMainDictId(locale);
diff --git a/java/src/com/android/inputmethod/latin/utils/DictionaryInfoUtils.java b/java/src/com/android/inputmethod/latin/utils/DictionaryInfoUtils.java
index cfa977a..096a545 100644
--- a/java/src/com/android/inputmethod/latin/utils/DictionaryInfoUtils.java
+++ b/java/src/com/android/inputmethod/latin/utils/DictionaryInfoUtils.java
@@ -30,6 +30,7 @@
 import com.android.inputmethod.latin.BinaryDictionaryGetter;
 import com.android.inputmethod.latin.R;
 import com.android.inputmethod.latin.RichInputMethodManager;
+import com.android.inputmethod.latin.common.FileUtils;
 import com.android.inputmethod.latin.common.LocaleUtils;
 import com.android.inputmethod.latin.define.DecoderSpecificConstants;
 import com.android.inputmethod.latin.makedict.DictionaryHeader;
@@ -153,6 +154,13 @@
     }
 
     /**
+     * Helper method to get the top level cache directory.
+     */
+    public static String getWordListStagingDirectory(final Context context) {
+        return context.getFilesDir() + File.separator + "staging";
+    }
+
+    /**
      * Helper method to get the top level temp directory.
      */
     public static String getWordListTempDirectory(final Context context) {
@@ -188,6 +196,10 @@
         return new File(DictionaryInfoUtils.getWordListCacheDirectory(context)).listFiles();
     }
 
+    public static File[] getStagingDirectoryList(final Context context) {
+        return new File(DictionaryInfoUtils.getWordListStagingDirectory(context)).listFiles();
+    }
+
     @Nullable
     public static File[] getUnusedDictionaryList(final Context context) {
         return context.getFilesDir().listFiles(new FilenameFilter() {
@@ -221,7 +233,7 @@
     /**
      * Find out the cache directory associated with a specific locale.
      */
-    private static String getCacheDirectoryForLocale(final String locale, final Context context) {
+    public static String getCacheDirectoryForLocale(final String locale, final Context context) {
         final String relativeDirectoryName = replaceFileNameDangerousCharacters(locale);
         final String absoluteDirectoryName = getWordListCacheDirectory(context) + File.separator
                 + relativeDirectoryName;
@@ -254,6 +266,52 @@
         return getCacheDirectoryForLocale(locale, context) + File.separator + fileName;
     }
 
+    public static String getStagingFileName(String id, String locale, Context context) {
+        final String stagingDirectory = getWordListStagingDirectory(context);
+        // create the directory if it does not exist.
+        final File directory = new File(stagingDirectory);
+        if (!directory.exists()) {
+            if (!directory.mkdirs()) {
+                Log.e(TAG, "Could not create the staging directory.");
+            }
+        }
+        // e.g. id="main:en_in", locale ="en_IN"
+        final String fileName = replaceFileNameDangerousCharacters(
+                locale + TEMP_DICT_FILE_SUB + id);
+        return stagingDirectory + File.separator + fileName;
+    }
+
+    public static void moveStagingFilesIfExists(Context context) {
+        final File[] stagingFiles = DictionaryInfoUtils.getStagingDirectoryList(context);
+        if (stagingFiles != null && stagingFiles.length > 0) {
+            for (final File stagingFile : stagingFiles) {
+                final String fileName = stagingFile.getName();
+                final int index = fileName.indexOf(TEMP_DICT_FILE_SUB);
+                if (index == -1) {
+                    // This should never happen.
+                    Log.e(TAG, "Staging file does not have ___ substring.");
+                    continue;
+                }
+                final String[] localeAndFileId = fileName.split(TEMP_DICT_FILE_SUB);
+                if (localeAndFileId.length != 2) {
+                    Log.e(TAG, String.format("malformed staging file %s. Deleting.",
+                            stagingFile.getAbsoluteFile()));
+                    stagingFile.delete();
+                    continue;
+                }
+
+                final String locale = localeAndFileId[0];
+                // already escaped while moving to staging.
+                final String fileId = localeAndFileId[1];
+                final String cacheDirectoryForLocale = getCacheDirectoryForLocale(locale, context);
+                final String cacheFilename = cacheDirectoryForLocale + File.separator + fileId;
+                final File cacheFile = new File(cacheFilename);
+                // move the staging file to cache file.
+                FileUtils.renameTo(stagingFile, cacheFile);
+            }
+        }
+    }
+
     public static boolean isMainWordListId(final String id) {
         final String[] idArray = id.split(BinaryDictionaryGetter.ID_CATEGORY_SEPARATOR);
         // An id is supposed to be in format category:locale, so splitting on the separator