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