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.makedict.DictDecoder; 25 import com.android.inputmethod.latin.makedict.FormatSpec; 26 import com.android.inputmethod.latin.makedict.FormatSpec.FileHeader; 27 import com.android.inputmethod.latin.makedict.UnsupportedFormatException; 28 import com.android.inputmethod.latin.utils.CollectionUtils; 29 import com.android.inputmethod.latin.utils.DictionaryInfoUtils; 30 import com.android.inputmethod.latin.utils.LocaleUtils; 31 32 import java.io.File; 33 import java.io.IOException; 34 import java.nio.BufferUnderflowException; 35 import java.util.ArrayList; 36 import java.util.HashMap; 37 import java.util.Locale; 38 39 /** 40 * Helper class to get the address of a mmap'able dictionary file. 41 */ 42 final public 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 final File directory = new File(DictionaryInfoUtils.getWordListTempDirectory(context)); 76 if (!directory.exists()) { 77 if (!directory.mkdirs()) { 78 Log.e(TAG, "Could not create the temporary directory"); 79 } 80 } 81 // If the first argument is less than three chars, createTempFile throws a 82 // RuntimeException. We don't really care about what name we get, so just 83 // put a three-chars prefix makes us safe. 84 return File.createTempFile("xxx" + safeId, null, directory).getAbsolutePath(); 85 } 86 87 /** 88 * Returns a file address from a resource, or null if it cannot be opened. 89 */ 90 public static AssetFileAddress loadFallbackResource(final Context context, 91 final int fallbackResId) { 92 final AssetFileDescriptor afd = context.getResources().openRawResourceFd(fallbackResId); 93 if (afd == null) { 94 Log.e(TAG, "Found the resource but cannot read it. Is it compressed? resId=" 95 + fallbackResId); 96 return null; 97 } 98 try { 99 return AssetFileAddress.makeFromFileNameAndOffset( 100 context.getApplicationInfo().sourceDir, afd.getStartOffset(), afd.getLength()); 101 } finally { 102 try { 103 afd.close(); 104 } catch (IOException e) { 105 // Ignored 106 } 107 } 108 } 109 110 private static final class DictPackSettings { 111 final SharedPreferences mDictPreferences; 112 public DictPackSettings(final Context context) { 113 mDictPreferences = null == context ? null 114 : context.getSharedPreferences(COMMON_PREFERENCES_NAME, 115 Context.MODE_WORLD_READABLE | Context.MODE_MULTI_PROCESS); 116 } 117 public boolean isWordListActive(final String dictId) { 118 if (null == mDictPreferences) { 119 // If we don't have preferences it basically means we can't find the dictionary 120 // pack - either it's not installed, or it's disabled, or there is some strange 121 // bug. Either way, a word list with no settings should be on by default: default 122 // dictionaries in LatinIME are on if there is no settings at all, and if for some 123 // reason some dictionaries have been installed BUT the dictionary pack can't be 124 // found anymore it's safer to actually supply installed dictionaries. 125 return true; 126 } else { 127 // The default is true here for the same reasons as above. We got the dictionary 128 // pack but if we don't have any settings for it it means the user has never been 129 // to the settings yet. So by default, the main dictionaries should be on. 130 return mDictPreferences.getBoolean(dictId, true); 131 } 132 } 133 } 134 135 /** 136 * Utility class for the {@link #getCachedWordLists} method 137 */ 138 private static final class FileAndMatchLevel { 139 final File mFile; 140 final int mMatchLevel; 141 public FileAndMatchLevel(final File file, final int matchLevel) { 142 mFile = file; 143 mMatchLevel = matchLevel; 144 } 145 } 146 147 /** 148 * Returns the list of cached files for a specific locale, one for each category. 149 * 150 * This will return exactly one file for each word list category that matches 151 * the passed locale. If several files match the locale for any given category, 152 * this returns the file with the closest match to the locale. For example, if 153 * the passed word list is en_US, and for a category we have an en and an en_US 154 * word list available, we'll return only the en_US one. 155 * Thus, the list will contain as many files as there are categories. 156 * 157 * @param locale the locale to find the dictionary files for, as a string. 158 * @param context the context on which to open the files upon. 159 * @return an array of binary dictionary files, which may be empty but may not be null. 160 */ 161 public static File[] getCachedWordLists(final String locale, final Context context) { 162 final File[] directoryList = DictionaryInfoUtils.getCachedDirectoryList(context); 163 if (null == directoryList) return EMPTY_FILE_ARRAY; 164 final HashMap<String, FileAndMatchLevel> cacheFiles = CollectionUtils.newHashMap(); 165 for (File directory : directoryList) { 166 if (!directory.isDirectory()) continue; 167 final String dirLocale = 168 DictionaryInfoUtils.getWordListIdFromFileName(directory.getName()); 169 final int matchLevel = LocaleUtils.getMatchLevel(dirLocale, locale); 170 if (LocaleUtils.isMatch(matchLevel)) { 171 final File[] wordLists = directory.listFiles(); 172 if (null != wordLists) { 173 for (File wordList : wordLists) { 174 final String category = 175 DictionaryInfoUtils.getCategoryFromFileName(wordList.getName()); 176 final FileAndMatchLevel currentBestMatch = cacheFiles.get(category); 177 if (null == currentBestMatch || currentBestMatch.mMatchLevel < matchLevel) { 178 cacheFiles.put(category, new FileAndMatchLevel(wordList, matchLevel)); 179 } 180 } 181 } 182 } 183 } 184 if (cacheFiles.isEmpty()) return EMPTY_FILE_ARRAY; 185 final File[] result = new File[cacheFiles.size()]; 186 int index = 0; 187 for (final FileAndMatchLevel entry : cacheFiles.values()) { 188 result[index++] = entry.mFile; 189 } 190 return result; 191 } 192 193 /** 194 * Remove all files with the passed id, except the passed file. 195 * 196 * If a dictionary with a given ID has a metadata change that causes it to change 197 * path, we need to remove the old version. The only way to do this is to check all 198 * installed files for a matching ID in a different directory. 199 */ 200 public static void removeFilesWithIdExcept(final Context context, final String id, 201 final File fileToKeep) { 202 try { 203 final File canonicalFileToKeep = fileToKeep.getCanonicalFile(); 204 final File[] directoryList = DictionaryInfoUtils.getCachedDirectoryList(context); 205 if (null == directoryList) return; 206 for (File directory : directoryList) { 207 // There is one directory per locale. See #getCachedDirectoryList 208 if (!directory.isDirectory()) continue; 209 final File[] wordLists = directory.listFiles(); 210 if (null == wordLists) continue; 211 for (File wordList : wordLists) { 212 final String fileId = 213 DictionaryInfoUtils.getWordListIdFromFileName(wordList.getName()); 214 if (fileId.equals(id)) { 215 if (!canonicalFileToKeep.equals(wordList.getCanonicalFile())) { 216 wordList.delete(); 217 } 218 } 219 } 220 } 221 } catch (java.io.IOException e) { 222 Log.e(TAG, "IOException trying to cleanup files", e); 223 } 224 } 225 226 // ## HACK ## we prevent usage of a dictionary before version 18. The reason for this is, since 227 // those do not include whitelist entries, the new code with an old version of the dictionary 228 // would lose whitelist functionality. 229 private static boolean hackCanUseDictionaryFile(final Locale locale, final File f) { 230 try { 231 // Read the version of the file 232 final DictDecoder dictDecoder = FormatSpec.getDictDecoder(f); 233 final FileHeader header = dictDecoder.readHeader(); 234 235 final String version = header.mDictionaryOptions.mAttributes.get(VERSION_KEY); 236 if (null == version) { 237 // No version in the options : the format is unexpected 238 return false; 239 } 240 // Version 18 is the first one to include the whitelist 241 // Obviously this is a big ## HACK ## 242 return Integer.parseInt(version) >= 18; 243 } catch (java.io.FileNotFoundException e) { 244 return false; 245 } catch (java.io.IOException e) { 246 return false; 247 } catch (NumberFormatException e) { 248 return false; 249 } catch (BufferUnderflowException e) { 250 return false; 251 } catch (UnsupportedFormatException e) { 252 return false; 253 } 254 } 255 256 /** 257 * Returns a list of file addresses for a given locale, trying relevant methods in order. 258 * 259 * Tries to get binary dictionaries from various sources, in order: 260 * - Uses a content provider to get a public dictionary set, as per the protocol described 261 * in BinaryDictionaryFileDumper. 262 * If that fails: 263 * - Gets a file name from the built-in dictionary for this locale, if any. 264 * If that fails: 265 * - Returns null. 266 * @return The list of addresses of valid dictionary files, or null. 267 */ 268 public static ArrayList<AssetFileAddress> getDictionaryFiles(final Locale locale, 269 final Context context) { 270 271 final boolean hasDefaultWordList = DictionaryFactory.isDictionaryAvailable(context, locale); 272 BinaryDictionaryFileDumper.cacheWordListsFromContentProvider(locale, context, 273 hasDefaultWordList); 274 final File[] cachedWordLists = getCachedWordLists(locale.toString(), context); 275 final String mainDictId = DictionaryInfoUtils.getMainDictId(locale); 276 final DictPackSettings dictPackSettings = new DictPackSettings(context); 277 278 boolean foundMainDict = false; 279 final ArrayList<AssetFileAddress> fileList = CollectionUtils.newArrayList(); 280 // cachedWordLists may not be null, see doc for getCachedDictionaryList 281 for (final File f : cachedWordLists) { 282 final String wordListId = DictionaryInfoUtils.getWordListIdFromFileName(f.getName()); 283 final boolean canUse = f.canRead() && hackCanUseDictionaryFile(locale, f); 284 if (canUse && DictionaryInfoUtils.isMainWordListId(wordListId)) { 285 foundMainDict = true; 286 } 287 if (!dictPackSettings.isWordListActive(wordListId)) continue; 288 if (canUse) { 289 final AssetFileAddress afa = AssetFileAddress.makeFromFileName(f.getPath()); 290 if (null != afa) fileList.add(afa); 291 } else { 292 Log.e(TAG, "Found a cached dictionary file but cannot read or use it"); 293 } 294 } 295 296 if (!foundMainDict && dictPackSettings.isWordListActive(mainDictId)) { 297 final int fallbackResId = 298 DictionaryInfoUtils.getMainDictionaryResourceId(context.getResources(), locale); 299 final AssetFileAddress fallbackAsset = loadFallbackResource(context, fallbackResId); 300 if (null != fallbackAsset) { 301 fileList.add(fallbackAsset); 302 } 303 } 304 305 return fileList; 306 } 307 } 308