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 android.content.Context;
20import android.content.SharedPreferences;
21import android.content.res.AssetFileDescriptor;
22import android.util.Log;
23
24import com.android.inputmethod.latin.common.LocaleUtils;
25import com.android.inputmethod.latin.define.DecoderSpecificConstants;
26import com.android.inputmethod.latin.makedict.DictionaryHeader;
27import com.android.inputmethod.latin.makedict.UnsupportedFormatException;
28import com.android.inputmethod.latin.utils.BinaryDictionaryUtils;
29import com.android.inputmethod.latin.utils.DictionaryInfoUtils;
30
31import java.io.File;
32import java.io.IOException;
33import java.nio.BufferUnderflowException;
34import java.util.ArrayList;
35import java.util.HashMap;
36import java.util.Locale;
37
38/**
39 * Helper class to get the address of a mmap'able dictionary file.
40 */
41final 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
69    private BinaryDictionaryGetter() {}
70
71    /**
72     * Generates a unique temporary file name in the app cache directory.
73     */
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     */
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;
118        public DictPackSettings(final Context context) {
119            mDictPreferences = null == context ? null
120                    : context.getSharedPreferences(COMMON_PREFERENCES_NAME,
121                            Context.MODE_MULTI_PROCESS);
122        }
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;
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     */
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.
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     */
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