BinaryDictionaryGetter.java revision 80e0bf04292867ddc769aca75ebaee817b95a941
1/*
2 * Copyright (C) 2011 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License"); you may not
5 * use this file except in compliance with the License. You may obtain a copy of
6 * 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, WITHOUT
12 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13 * License for the specific language governing permissions and limitations under
14 * the License.
15 */
16
17package com.android.inputmethod.latin;
18
19import android.content.Context;
20import android.content.SharedPreferences;
21import android.content.pm.PackageManager.NameNotFoundException;
22import android.content.res.AssetFileDescriptor;
23import android.content.res.Resources;
24import android.util.Log;
25
26import java.io.File;
27import java.io.FileNotFoundException;
28import java.io.IOException;
29import java.util.Arrays;
30import java.util.ArrayList;
31import java.util.List;
32import java.util.Locale;
33
34/**
35 * Helper class to get the address of a mmap'able dictionary file.
36 */
37class BinaryDictionaryGetter {
38
39    /**
40     * Used for Log actions from this class
41     */
42    private static final String TAG = BinaryDictionaryGetter.class.getSimpleName();
43
44    /**
45     * Name of the common preferences name to know which word list are on and which are off.
46     */
47    private static final String COMMON_PREFERENCES_NAME = "LatinImeDictPrefs";
48
49    // Prevents this from being instantiated
50    private BinaryDictionaryGetter() {}
51
52    /**
53     * Returns whether we may want to use this character as part of a file name.
54     *
55     * This basically only accepts ascii letters and numbers, and rejects everything else.
56     */
57    private static boolean isFileNameCharacter(int codePoint) {
58        if (codePoint >= 0x30 && codePoint <= 0x39) return true; // Digit
59        if (codePoint >= 0x41 && codePoint <= 0x5A) return true; // Uppercase
60        if (codePoint >= 0x61 && codePoint <= 0x7A) return true; // Lowercase
61        return codePoint == '_'; // Underscore
62    }
63
64    /**
65     * Escapes a string for any characters that may be suspicious for a file or directory name.
66     *
67     * Concretely this does a sort of URL-encoding except it will encode everything that's not
68     * alphanumeric or underscore. (true URL-encoding leaves alone characters like '*', which
69     * we cannot allow here)
70     */
71    // TODO: create a unit test for this method
72    private static String replaceFileNameDangerousCharacters(final String name) {
73        // This assumes '%' is fully available as a non-separator, normal
74        // character in a file name. This is probably true for all file systems.
75        final StringBuilder sb = new StringBuilder();
76        for (int i = 0; i < name.length(); ++i) {
77            final int codePoint = name.codePointAt(i);
78            if (isFileNameCharacter(codePoint)) {
79                sb.appendCodePoint(codePoint);
80            } else {
81                // 6 digits - unicode is limited to 21 bits
82                sb.append(String.format((Locale)null, "%%%1$06x", codePoint));
83            }
84        }
85        return sb.toString();
86    }
87
88    /**
89     * Reverse escaping done by replaceFileNameDangerousCharacters.
90     */
91    private static String getWordListIdFromFileName(final String fname) {
92        final StringBuilder sb = new StringBuilder();
93        for (int i = 0; i < fname.length(); ++i) {
94            final int codePoint = fname.codePointAt(i);
95            if ('%' != codePoint) {
96                sb.appendCodePoint(codePoint);
97            } else {
98                final int encodedCodePoint = Integer.parseInt(fname.substring(i + 1, i + 7), 16);
99                i += 6;
100                sb.appendCodePoint(encodedCodePoint);
101            }
102        }
103        return sb.toString();
104    }
105
106    /**
107     * Find out the cache directory associated with a specific locale.
108     */
109    private static String getCacheDirectoryForLocale(Locale locale, Context context) {
110        final String relativeDirectoryName = replaceFileNameDangerousCharacters(locale.toString());
111        final String absoluteDirectoryName = context.getFilesDir() + File.separator
112                + relativeDirectoryName;
113        final File directory = new File(absoluteDirectoryName);
114        if (!directory.exists()) {
115            if (!directory.mkdirs()) {
116                Log.e(TAG, "Could not create the directory for locale" + locale);
117            }
118        }
119        return absoluteDirectoryName;
120    }
121
122    /**
123     * Generates a file name for the id and locale passed as an argument.
124     *
125     * In the current implementation the file name returned will always be unique for
126     * any id/locale pair, but please do not expect that the id can be the same for
127     * different dictionaries with different locales. An id should be unique for any
128     * dictionary.
129     * The file name is pretty much an URL-encoded version of the id inside a directory
130     * named like the locale, except it will also escape characters that look dangerous
131     * to some file systems.
132     * @param id the id of the dictionary for which to get a file name
133     * @param locale the locale for which to get the file name
134     * @param context the context to use for getting the directory
135     * @return the name of the file to be created
136     */
137    public static String getCacheFileName(String id, Locale locale, Context context) {
138        final String fileName = replaceFileNameDangerousCharacters(id);
139        return getCacheDirectoryForLocale(locale, context) + File.separator + fileName;
140    }
141
142    /**
143     * Returns a file address from a resource, or null if it cannot be opened.
144     */
145    private static AssetFileAddress loadFallbackResource(final Context context,
146            final int fallbackResId, final Locale locale) {
147        final Resources res = context.getResources();
148        final Locale savedLocale = Utils.setSystemLocale(res, locale);
149        final AssetFileDescriptor afd = res.openRawResourceFd(fallbackResId);
150        Utils.setSystemLocale(res, savedLocale);
151
152        if (afd == null) {
153            Log.e(TAG, "Found the resource but cannot read it. Is it compressed? resId="
154                    + fallbackResId);
155            return null;
156        }
157        return AssetFileAddress.makeFromFileNameAndOffset(
158                context.getApplicationInfo().sourceDir, afd.getStartOffset(), afd.getLength());
159    }
160
161    /**
162     * Returns the list of cached files for a specific locale.
163     *
164     * @param locale the locale to find the dictionary files for.
165     * @param context the context on which to open the files upon.
166     * @return a list of binary dictionary files, which may be null but may not be empty.
167     */
168    private static List<AssetFileAddress> getCachedDictionaryList(final Locale locale,
169            final Context context) {
170        final String directoryName = getCacheDirectoryForLocale(locale, context);
171        final File[] cacheFiles = new File(directoryName).listFiles();
172        // TODO: Never return null. Fallback on the built-in dictionary, and if that's
173        // not present or disabled, then return an empty list.
174        if (null == cacheFiles) return null;
175
176        final SharedPreferences dictPackSettings;
177        try {
178            final String dictPackName = context.getString(R.string.dictionary_pack_package_name);
179            final Context dictPackContext = context.createPackageContext(dictPackName, 0);
180            dictPackSettings = dictPackContext.getSharedPreferences(COMMON_PREFERENCES_NAME,
181                    Context.MODE_WORLD_READABLE | Context.MODE_MULTI_PROCESS);
182        } catch (NameNotFoundException e) {
183            // The dictionary pack is not installed...
184            // TODO: fallback on the built-in dict, see the TODO above
185            Log.e(TAG, "Could not find a dictionary pack");
186            return null;
187        }
188
189        final ArrayList<AssetFileAddress> fileList = new ArrayList<AssetFileAddress>();
190        for (File f : cacheFiles) {
191            final String wordListId = getWordListIdFromFileName(f.getName());
192            final boolean isActive = dictPackSettings.getBoolean(wordListId, true);
193            if (!isActive) continue;
194            if (f.canRead()) {
195                fileList.add(AssetFileAddress.makeFromFileName(f.getPath()));
196            } else {
197                Log.e(TAG, "Found a cached dictionary file but cannot read it");
198            }
199        }
200        return fileList.size() > 0 ? fileList : null;
201    }
202
203    /**
204     * Returns a list of file addresses for a given locale, trying relevant methods in order.
205     *
206     * Tries to get binary dictionaries from various sources, in order:
207     * - Uses a content provider to get a public dictionary set, as per the protocol described
208     *   in BinaryDictionaryFileDumper.
209     * If that fails:
210     * - Gets a file name from the fallback resource passed as an argument.
211     * If that fails:
212     * - Returns null.
213     * @return The address of a valid file, or null.
214     */
215    public static List<AssetFileAddress> getDictionaryFiles(final Locale locale,
216            final Context context, final int fallbackResId) {
217
218        // cacheDictionariesFromContentProvider returns the list of files it copied to local
219        // storage, but we don't really care about what was copied NOW: what we want is the
220        // list of everything we ever cached, so we ignore the return value.
221        BinaryDictionaryFileDumper.cacheDictionariesFromContentProvider(locale, context);
222        List<AssetFileAddress> cachedDictionaryList = getCachedDictionaryList(locale, context);
223        if (null != cachedDictionaryList) {
224            return cachedDictionaryList;
225        }
226
227        final AssetFileAddress fallbackAsset = loadFallbackResource(context, fallbackResId,
228                locale);
229        if (null == fallbackAsset) return null;
230        return Arrays.asList(fallbackAsset);
231    }
232}
233