BinaryDictionaryGetter.java revision ce487bcf33be39eed4ed56e6b98603cc87fda2eb
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
17package com.android.inputmethod.latin;
18
19import com.android.inputmethod.latin.define.ProductionFlag;
20import com.android.inputmethod.latin.makedict.BinaryDictInputOutput;
21import com.android.inputmethod.latin.makedict.FormatSpec;
22
23import android.content.Context;
24import android.content.SharedPreferences;
25import android.content.pm.PackageManager;
26import android.content.pm.PackageManager.NameNotFoundException;
27import android.content.res.AssetFileDescriptor;
28import android.util.Log;
29
30import java.io.File;
31import java.io.FileInputStream;
32import java.io.IOException;
33import java.nio.BufferUnderflowException;
34import java.nio.channels.FileChannel;
35import java.util.ArrayList;
36import java.util.HashMap;
37import java.util.Locale;
38
39/**
40 * Helper class to get the address of a mmap'able dictionary file.
41 */
42final class BinaryDictionaryGetter {
43
44    /**
45     * Used for Log actions from this class
46     */
47    private static final String TAG = BinaryDictionaryGetter.class.getSimpleName();
48
49    /**
50     * Used to return empty lists
51     */
52    private static final File[] EMPTY_FILE_ARRAY = new File[0];
53
54    /**
55     * Name of the common preferences name to know which word list are on and which are off.
56     */
57    private static final String COMMON_PREFERENCES_NAME = "LatinImeDictPrefs";
58
59    // Name of the category for the main dictionary
60    public static final String MAIN_DICTIONARY_CATEGORY = "main";
61    public static final String ID_CATEGORY_SEPARATOR = ":";
62
63    // The key considered to read the version attribute in a dictionary file.
64    private static String VERSION_KEY = "version";
65
66    // Prevents this from being instantiated
67    private BinaryDictionaryGetter() {}
68
69    /**
70     * Generates a unique temporary file name in the app cache directory.
71     */
72    public static String getTempFileName(final String id, final Context context)
73            throws IOException {
74        final String safeId = DictionaryInfoUtils.replaceFileNameDangerousCharacters(id);
75        // If the first argument is less than three chars, createTempFile throws a
76        // RuntimeException. We don't really care about what name we get, so just
77        // put a three-chars prefix makes us safe.
78        return File.createTempFile("xxx" + safeId, null).getAbsolutePath();
79    }
80
81    /**
82     * Returns a file address from a resource, or null if it cannot be opened.
83     */
84    public static AssetFileAddress loadFallbackResource(final Context context,
85            final int fallbackResId) {
86        final AssetFileDescriptor afd = context.getResources().openRawResourceFd(fallbackResId);
87        if (afd == null) {
88            Log.e(TAG, "Found the resource but cannot read it. Is it compressed? resId="
89                    + fallbackResId);
90            return null;
91        }
92        return AssetFileAddress.makeFromFileNameAndOffset(
93                context.getApplicationInfo().sourceDir, afd.getStartOffset(), afd.getLength());
94    }
95
96    private static final class DictPackSettings {
97        final SharedPreferences mDictPreferences;
98        public DictPackSettings(final Context context) {
99            Context dictPackContext = null;
100            try {
101                final String dictPackName =
102                        context.getString(R.string.dictionary_pack_package_name);
103                dictPackContext = context.createPackageContext(dictPackName, 0);
104            } catch (NameNotFoundException e) {
105                // The dictionary pack is not installed...
106                // TODO: fallback on the built-in dict, see the TODO above
107                Log.e(TAG, "Could not find a dictionary pack");
108            }
109            mDictPreferences = null == dictPackContext ? null
110                    : dictPackContext.getSharedPreferences(COMMON_PREFERENCES_NAME,
111                            Context.MODE_WORLD_READABLE | Context.MODE_MULTI_PROCESS);
112        }
113        public boolean isWordListActive(final String dictId) {
114            if (null == mDictPreferences) {
115                // If we don't have preferences it basically means we can't find the dictionary
116                // pack - either it's not installed, or it's disabled, or there is some strange
117                // bug. Either way, a word list with no settings should be on by default: default
118                // dictionaries in LatinIME are on if there is no settings at all, and if for some
119                // reason some dictionaries have been installed BUT the dictionary pack can't be
120                // found anymore it's safer to actually supply installed dictionaries.
121                return true;
122            } else {
123                // The default is true here for the same reasons as above. We got the dictionary
124                // pack but if we don't have any settings for it it means the user has never been
125                // to the settings yet. So by default, the main dictionaries should be on.
126                return mDictPreferences.getBoolean(dictId, true);
127            }
128        }
129    }
130
131    /**
132     * Utility class for the {@link #getCachedWordLists} method
133     */
134    private static final class FileAndMatchLevel {
135        final File mFile;
136        final int mMatchLevel;
137        public FileAndMatchLevel(final File file, final int matchLevel) {
138            mFile = file;
139            mMatchLevel = matchLevel;
140        }
141    }
142
143    /**
144     * Returns the list of cached files for a specific locale, one for each category.
145     *
146     * This will return exactly one file for each word list category that matches
147     * the passed locale. If several files match the locale for any given category,
148     * this returns the file with the closest match to the locale. For example, if
149     * the passed word list is en_US, and for a category we have an en and an en_US
150     * word list available, we'll return only the en_US one.
151     * Thus, the list will contain as many files as there are categories.
152     *
153     * @param locale the locale to find the dictionary files for, as a string.
154     * @param context the context on which to open the files upon.
155     * @return an array of binary dictionary files, which may be empty but may not be null.
156     */
157    public static File[] getCachedWordLists(final String locale, final Context context) {
158        final File[] directoryList = DictionaryInfoUtils.getCachedDirectoryList(context);
159        if (null == directoryList) return EMPTY_FILE_ARRAY;
160        final HashMap<String, FileAndMatchLevel> cacheFiles = CollectionUtils.newHashMap();
161        for (File directory : directoryList) {
162            if (!directory.isDirectory()) continue;
163            final String dirLocale =
164                    DictionaryInfoUtils.getWordListIdFromFileName(directory.getName());
165            final int matchLevel = LocaleUtils.getMatchLevel(dirLocale, locale);
166            if (LocaleUtils.isMatch(matchLevel)) {
167                final File[] wordLists = directory.listFiles();
168                if (null != wordLists) {
169                    for (File wordList : wordLists) {
170                        final String category =
171                                DictionaryInfoUtils.getCategoryFromFileName(wordList.getName());
172                        final FileAndMatchLevel currentBestMatch = cacheFiles.get(category);
173                        if (null == currentBestMatch || currentBestMatch.mMatchLevel < matchLevel) {
174                            cacheFiles.put(category, new FileAndMatchLevel(wordList, matchLevel));
175                        }
176                    }
177                }
178            }
179        }
180        if (cacheFiles.isEmpty()) return EMPTY_FILE_ARRAY;
181        final File[] result = new File[cacheFiles.size()];
182        int index = 0;
183        for (final FileAndMatchLevel entry : cacheFiles.values()) {
184            result[index++] = entry.mFile;
185        }
186        return result;
187    }
188
189    /**
190     * Remove all files with the passed id, except the passed file.
191     *
192     * If a dictionary with a given ID has a metadata change that causes it to change
193     * path, we need to remove the old version. The only way to do this is to check all
194     * installed files for a matching ID in a different directory.
195     */
196    public static void removeFilesWithIdExcept(final Context context, final String id,
197            final File fileToKeep) {
198        try {
199            final File canonicalFileToKeep = fileToKeep.getCanonicalFile();
200            final File[] directoryList = DictionaryInfoUtils.getCachedDirectoryList(context);
201            if (null == directoryList) return;
202            for (File directory : directoryList) {
203                // There is one directory per locale. See #getCachedDirectoryList
204                if (!directory.isDirectory()) continue;
205                final File[] wordLists = directory.listFiles();
206                if (null == wordLists) continue;
207                for (File wordList : wordLists) {
208                    final String fileId =
209                            DictionaryInfoUtils.getWordListIdFromFileName(wordList.getName());
210                    if (fileId.equals(id)) {
211                        if (!canonicalFileToKeep.equals(wordList.getCanonicalFile())) {
212                            wordList.delete();
213                        }
214                    }
215                }
216            }
217        } catch (java.io.IOException e) {
218            Log.e(TAG, "IOException trying to cleanup files", e);
219        }
220    }
221
222    // ## HACK ## we prevent usage of a dictionary before version 18 for English only. The reason
223    // for this is, since those do not include whitelist entries, the new code with an old version
224    // of the dictionary would lose whitelist functionality.
225    private static boolean hackCanUseDictionaryFile(final Locale locale, final File f) {
226        // Only for English - other languages didn't have a whitelist, hence this
227        // ad-hoc ## HACK ##
228        if (!Locale.ENGLISH.getLanguage().equals(locale.getLanguage())) return true;
229
230        FileInputStream inStream = null;
231        try {
232            // Read the version of the file
233            inStream = new FileInputStream(f);
234            final BinaryDictInputOutput.ByteBufferWrapper buffer =
235                    new BinaryDictInputOutput.ByteBufferWrapper(inStream.getChannel().map(
236                            FileChannel.MapMode.READ_ONLY, 0, f.length()));
237            final int magic = buffer.readInt();
238            if (magic != FormatSpec.VERSION_2_MAGIC_NUMBER) {
239                return false;
240            }
241            final int formatVersion = buffer.readInt();
242            final int headerSize = buffer.readInt();
243            final HashMap<String, String> options = CollectionUtils.newHashMap();
244            BinaryDictInputOutput.populateOptions(buffer, headerSize, options);
245
246            final String version = options.get(VERSION_KEY);
247            if (null == version) {
248                // No version in the options : the format is unexpected
249                return false;
250            }
251            // Version 18 is the first one to include the whitelist
252            // Obviously this is a big ## HACK ##
253            return Integer.parseInt(version) >= 18;
254        } catch (java.io.FileNotFoundException e) {
255            return false;
256        } catch (java.io.IOException e) {
257            return false;
258        } catch (NumberFormatException e) {
259            return false;
260        } catch (BufferUnderflowException e) {
261            return false;
262        } finally {
263            if (inStream != null) {
264                try {
265                    inStream.close();
266                } catch (IOException e) {
267                    // do nothing
268                }
269            }
270        }
271    }
272
273    /**
274     * Returns a list of file addresses for a given locale, trying relevant methods in order.
275     *
276     * Tries to get binary dictionaries from various sources, in order:
277     * - Uses a content provider to get a public dictionary set, as per the protocol described
278     *   in BinaryDictionaryFileDumper.
279     * If that fails:
280     * - Gets a file name from the built-in dictionary for this locale, if any.
281     * If that fails:
282     * - Returns null.
283     * @return The list of addresses of valid dictionary files, or null.
284     */
285    public static ArrayList<AssetFileAddress> getDictionaryFiles(final Locale locale,
286            final Context context) {
287
288        final boolean hasDefaultWordList = DictionaryFactory.isDictionaryAvailable(context, locale);
289        // cacheWordListsFromContentProvider returns the list of files it copied to local
290        // storage, but we don't really care about what was copied NOW: what we want is the
291        // list of everything we ever cached, so we ignore the return value.
292        // TODO: The experimental version is not supported by the Dictionary Pack Service yet
293        if (!ProductionFlag.IS_EXPERIMENTAL) {
294            // We need internet access to do the following. Only do this if the package actually
295            // has the permission.
296            if (context.checkCallingOrSelfPermission(android.Manifest.permission.INTERNET)
297                    == PackageManager.PERMISSION_GRANTED) {
298                BinaryDictionaryFileDumper.cacheWordListsFromContentProvider(locale, context,
299                        hasDefaultWordList);
300            }
301        }
302        final File[] cachedWordLists = getCachedWordLists(locale.toString(), context);
303        final String mainDictId = DictionaryInfoUtils.getMainDictId(locale);
304        final DictPackSettings dictPackSettings = new DictPackSettings(context);
305
306        boolean foundMainDict = false;
307        final ArrayList<AssetFileAddress> fileList = CollectionUtils.newArrayList();
308        // cachedWordLists may not be null, see doc for getCachedDictionaryList
309        for (final File f : cachedWordLists) {
310            final String wordListId = DictionaryInfoUtils.getWordListIdFromFileName(f.getName());
311            final boolean canUse = f.canRead() && hackCanUseDictionaryFile(locale, f);
312            if (canUse && DictionaryInfoUtils.isMainWordListId(wordListId)) {
313                foundMainDict = true;
314            }
315            if (!dictPackSettings.isWordListActive(wordListId)) continue;
316            if (canUse) {
317                fileList.add(AssetFileAddress.makeFromFileName(f.getPath()));
318            } else {
319                Log.e(TAG, "Found a cached dictionary file but cannot read or use it");
320            }
321        }
322
323        if (!foundMainDict && dictPackSettings.isWordListActive(mainDictId)) {
324            final int fallbackResId =
325                    DictionaryInfoUtils.getMainDictionaryResourceId(context.getResources(), locale);
326            final AssetFileAddress fallbackAsset = loadFallbackResource(context, fallbackResId);
327            if (null != fallbackAsset) {
328                fileList.add(fallbackAsset);
329            }
330        }
331
332        return fileList;
333    }
334}
335