1 /*
2  * Copyright (C) 2011 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.Context;
20 import android.content.SharedPreferences;
21 import android.content.res.AssetFileDescriptor;
22 import android.util.Log;
23 
24 import com.android.inputmethod.latin.common.LocaleUtils;
25 import com.android.inputmethod.latin.define.DecoderSpecificConstants;
26 import com.android.inputmethod.latin.makedict.DictionaryHeader;
27 import com.android.inputmethod.latin.makedict.UnsupportedFormatException;
28 import com.android.inputmethod.latin.utils.BinaryDictionaryUtils;
29 import com.android.inputmethod.latin.utils.DictionaryInfoUtils;
30 
31 import java.io.File;
32 import java.io.IOException;
33 import java.nio.BufferUnderflowException;
34 import java.util.ArrayList;
35 import java.util.HashMap;
36 import java.util.Locale;
37 
38 /**
39  * Helper class to get the address of a mmap'able dictionary file.
40  */
41 final public class BinaryDictionaryGetter {
42 
43     /**
44      * Used for Log actions from this class
45      */
46     private static final String TAG = BinaryDictionaryGetter.class.getSimpleName();
47 
48     /**
49      * Used to return empty lists
50      */
51     private static final File[] EMPTY_FILE_ARRAY = new File[0];
52 
53     /**
54      * Name of the common preferences name to know which word list are on and which are off.
55      */
56     private static final String COMMON_PREFERENCES_NAME = "LatinImeDictPrefs";
57 
58     private static final boolean SHOULD_USE_DICT_VERSION =
59             DecoderSpecificConstants.SHOULD_USE_DICT_VERSION;
60 
61     // Name of the category for the main dictionary
62     public static final String MAIN_DICTIONARY_CATEGORY = "main";
63     public static final String ID_CATEGORY_SEPARATOR = ":";
64 
65     // The key considered to read the version attribute in a dictionary file.
66     private static String VERSION_KEY = "version";
67 
68     // Prevents this from being instantiated
BinaryDictionaryGetter()69     private BinaryDictionaryGetter() {}
70 
71     /**
72      * Generates a unique temporary file name in the app cache directory.
73      */
getTempFileName(final String id, final Context context)74     public static String getTempFileName(final String id, final Context context)
75             throws IOException {
76         final String safeId = DictionaryInfoUtils.replaceFileNameDangerousCharacters(id);
77         final File directory = new File(DictionaryInfoUtils.getWordListTempDirectory(context));
78         if (!directory.exists()) {
79             if (!directory.mkdirs()) {
80                 Log.e(TAG, "Could not create the temporary directory");
81             }
82         }
83         // If the first argument is less than three chars, createTempFile throws a
84         // RuntimeException. We don't really care about what name we get, so just
85         // put a three-chars prefix makes us safe.
86         return File.createTempFile("xxx" + safeId, null, directory).getAbsolutePath();
87     }
88 
89     /**
90      * Returns a file address from a resource, or null if it cannot be opened.
91      */
loadFallbackResource(final Context context, final int fallbackResId)92     public static AssetFileAddress loadFallbackResource(final Context context,
93             final int fallbackResId) {
94         AssetFileDescriptor afd = null;
95         try {
96             afd = context.getResources().openRawResourceFd(fallbackResId);
97         } catch (RuntimeException e) {
98             Log.e(TAG, "Resource not found: " + fallbackResId);
99             return null;
100         }
101         if (afd == null) {
102             Log.e(TAG, "Resource cannot be opened: " + fallbackResId);
103             return null;
104         }
105         try {
106             return AssetFileAddress.makeFromFileNameAndOffset(
107                     context.getApplicationInfo().sourceDir, afd.getStartOffset(), afd.getLength());
108         } finally {
109             try {
110                 afd.close();
111             } catch (IOException ignored) {
112             }
113         }
114     }
115 
116     private static final class DictPackSettings {
117         final SharedPreferences mDictPreferences;
DictPackSettings(final Context context)118         public DictPackSettings(final Context context) {
119             mDictPreferences = null == context ? null
120                     : context.getSharedPreferences(COMMON_PREFERENCES_NAME,
121                             Context.MODE_MULTI_PROCESS);
122         }
isWordListActive(final String dictId)123         public boolean isWordListActive(final String dictId) {
124             if (null == mDictPreferences) {
125                 // If we don't have preferences it basically means we can't find the dictionary
126                 // pack - either it's not installed, or it's disabled, or there is some strange
127                 // bug. Either way, a word list with no settings should be on by default: default
128                 // dictionaries in LatinIME are on if there is no settings at all, and if for some
129                 // reason some dictionaries have been installed BUT the dictionary pack can't be
130                 // found anymore it's safer to actually supply installed dictionaries.
131                 return true;
132             }
133             // The default is true here for the same reasons as above. We got the dictionary
134             // pack but if we don't have any settings for it it means the user has never been
135             // to the settings yet. So by default, the main dictionaries should be on.
136             return mDictPreferences.getBoolean(dictId, true);
137         }
138     }
139 
140     /**
141      * Utility class for the {@link #getCachedWordLists} method
142      */
143     private static final class FileAndMatchLevel {
144         final File mFile;
145         final int mMatchLevel;
FileAndMatchLevel(final File file, final int matchLevel)146         public FileAndMatchLevel(final File file, final int matchLevel) {
147             mFile = file;
148             mMatchLevel = matchLevel;
149         }
150     }
151 
152     /**
153      * Returns the list of cached files for a specific locale, one for each category.
154      *
155      * This will return exactly one file for each word list category that matches
156      * the passed locale. If several files match the locale for any given category,
157      * this returns the file with the closest match to the locale. For example, if
158      * the passed word list is en_US, and for a category we have an en and an en_US
159      * word list available, we'll return only the en_US one.
160      * Thus, the list will contain as many files as there are categories.
161      *
162      * @param locale the locale to find the dictionary files for, as a string.
163      * @param context the context on which to open the files upon.
164      * @return an array of binary dictionary files, which may be empty but may not be null.
165      */
getCachedWordLists(final String locale, final Context context)166     public static File[] getCachedWordLists(final String locale, final Context context) {
167         final File[] directoryList = DictionaryInfoUtils.getCachedDirectoryList(context);
168         if (null == directoryList) return EMPTY_FILE_ARRAY;
169         final HashMap<String, FileAndMatchLevel> cacheFiles = new HashMap<>();
170         for (File directory : directoryList) {
171             if (!directory.isDirectory()) continue;
172             final String dirLocale =
173                     DictionaryInfoUtils.getWordListIdFromFileName(directory.getName());
174             final int matchLevel = LocaleUtils.getMatchLevel(dirLocale, locale);
175             if (LocaleUtils.isMatch(matchLevel)) {
176                 final File[] wordLists = directory.listFiles();
177                 if (null != wordLists) {
178                     for (File wordList : wordLists) {
179                         final String category =
180                                 DictionaryInfoUtils.getCategoryFromFileName(wordList.getName());
181                         final FileAndMatchLevel currentBestMatch = cacheFiles.get(category);
182                         if (null == currentBestMatch || currentBestMatch.mMatchLevel < matchLevel) {
183                             cacheFiles.put(category, new FileAndMatchLevel(wordList, matchLevel));
184                         }
185                     }
186                 }
187             }
188         }
189         if (cacheFiles.isEmpty()) return EMPTY_FILE_ARRAY;
190         final File[] result = new File[cacheFiles.size()];
191         int index = 0;
192         for (final FileAndMatchLevel entry : cacheFiles.values()) {
193             result[index++] = entry.mFile;
194         }
195         return result;
196     }
197 
198     // ## HACK ## we prevent usage of a dictionary before version 18. The reason for this is, since
199     // those do not include whitelist entries, the new code with an old version of the dictionary
200     // would lose whitelist functionality.
hackCanUseDictionaryFile(final File file)201     private static boolean hackCanUseDictionaryFile(final File file) {
202         if (!SHOULD_USE_DICT_VERSION) {
203             return true;
204         }
205 
206         try {
207             // Read the version of the file
208             final DictionaryHeader header = BinaryDictionaryUtils.getHeader(file);
209             final String version = header.mDictionaryOptions.mAttributes.get(VERSION_KEY);
210             if (null == version) {
211                 // No version in the options : the format is unexpected
212                 return false;
213             }
214             // Version 18 is the first one to include the whitelist
215             // Obviously this is a big ## HACK ##
216             return Integer.parseInt(version) >= 18;
217         } catch (java.io.FileNotFoundException e) {
218             return false;
219         } catch (java.io.IOException e) {
220             return false;
221         } catch (NumberFormatException e) {
222             return false;
223         } catch (BufferUnderflowException e) {
224             return false;
225         } catch (UnsupportedFormatException e) {
226             return false;
227         }
228     }
229 
230     /**
231      * Returns a list of file addresses for a given locale, trying relevant methods in order.
232      *
233      * Tries to get binary dictionaries from various sources, in order:
234      * - Uses a content provider to get a public dictionary set, as per the protocol described
235      *   in BinaryDictionaryFileDumper.
236      * If that fails:
237      * - Gets a file name from the built-in dictionary for this locale, if any.
238      * If that fails:
239      * - Returns null.
240      * @return The list of addresses of valid dictionary files, or null.
241      */
getDictionaryFiles(final Locale locale, final Context context, boolean notifyDictionaryPackForUpdates)242     public static ArrayList<AssetFileAddress> getDictionaryFiles(final Locale locale,
243             final Context context, boolean notifyDictionaryPackForUpdates) {
244         if (notifyDictionaryPackForUpdates) {
245             final boolean hasDefaultWordList = DictionaryInfoUtils.isDictionaryAvailable(
246                     context, locale);
247             // It makes sure that the first time keyboard comes up and the dictionaries are reset,
248             // the DB is populated with the appropriate values for each locale. Helps in downloading
249             // the dictionaries when the user enables and switches new languages before the
250             // DictionaryService runs.
251             BinaryDictionaryFileDumper.downloadDictIfNeverRequested(
252                     locale, context, hasDefaultWordList);
253 
254             // Move a staging files to the cache ddirectories if any.
255             DictionaryInfoUtils.moveStagingFilesIfExists(context);
256         }
257         final File[] cachedWordLists = getCachedWordLists(locale.toString(), context);
258         final String mainDictId = DictionaryInfoUtils.getMainDictId(locale);
259         final DictPackSettings dictPackSettings = new DictPackSettings(context);
260 
261         boolean foundMainDict = false;
262         final ArrayList<AssetFileAddress> fileList = new ArrayList<>();
263         // cachedWordLists may not be null, see doc for getCachedDictionaryList
264         for (final File f : cachedWordLists) {
265             final String wordListId = DictionaryInfoUtils.getWordListIdFromFileName(f.getName());
266             final boolean canUse = f.canRead() && hackCanUseDictionaryFile(f);
267             if (canUse && DictionaryInfoUtils.isMainWordListId(wordListId)) {
268                 foundMainDict = true;
269             }
270             if (!dictPackSettings.isWordListActive(wordListId)) continue;
271             if (canUse) {
272                 final AssetFileAddress afa = AssetFileAddress.makeFromFileName(f.getPath());
273                 if (null != afa) fileList.add(afa);
274             } else {
275                 Log.e(TAG, "Found a cached dictionary file for " + locale.toString()
276                         + " but cannot read or use it");
277             }
278         }
279 
280         if (!foundMainDict && dictPackSettings.isWordListActive(mainDictId)) {
281             final int fallbackResId =
282                     DictionaryInfoUtils.getMainDictionaryResourceId(context.getResources(), locale);
283             final AssetFileAddress fallbackAsset = loadFallbackResource(context, fallbackResId);
284             if (null != fallbackAsset) {
285                 fileList.add(fallbackAsset);
286             }
287         }
288 
289         return fileList;
290     }
291 }
292