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 com.android.inputmethod.latin.define.ProductionFlag; 20 import com.android.inputmethod.latin.makedict.BinaryDictInputOutput; 21 import com.android.inputmethod.latin.makedict.FormatSpec; 22 23 import android.content.Context; 24 import android.content.SharedPreferences; 25 import android.content.pm.PackageManager; 26 import android.content.pm.PackageManager.NameNotFoundException; 27 import android.content.res.AssetFileDescriptor; 28 import android.util.Log; 29 30 import java.io.File; 31 import java.io.FileInputStream; 32 import java.io.IOException; 33 import java.nio.BufferUnderflowException; 34 import java.nio.channels.FileChannel; 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 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 for English only. The reason 227 // for this is, since those do not include whitelist entries, the new code with an old version 228 // of the dictionary would lose whitelist functionality. 229 private static boolean hackCanUseDictionaryFile(final Locale locale, final File f) { 230 // Only for English - other languages didn't have a whitelist, hence this 231 // ad-hoc ## HACK ## 232 if (!Locale.ENGLISH.getLanguage().equals(locale.getLanguage())) return true; 233 234 FileInputStream inStream = null; 235 try { 236 // Read the version of the file 237 inStream = new FileInputStream(f); 238 final BinaryDictInputOutput.ByteBufferWrapper buffer = 239 new BinaryDictInputOutput.ByteBufferWrapper(inStream.getChannel().map( 240 FileChannel.MapMode.READ_ONLY, 0, f.length())); 241 final int magic = buffer.readInt(); 242 if (magic != FormatSpec.VERSION_2_MAGIC_NUMBER) { 243 return false; 244 } 245 final int formatVersion = buffer.readInt(); 246 final int headerSize = buffer.readInt(); 247 final HashMap<String, String> options = CollectionUtils.newHashMap(); 248 BinaryDictInputOutput.populateOptions(buffer, headerSize, options); 249 250 final String version = options.get(VERSION_KEY); 251 if (null == version) { 252 // No version in the options : the format is unexpected 253 return false; 254 } 255 // Version 18 is the first one to include the whitelist 256 // Obviously this is a big ## HACK ## 257 return Integer.parseInt(version) >= 18; 258 } catch (java.io.FileNotFoundException e) { 259 return false; 260 } catch (java.io.IOException e) { 261 return false; 262 } catch (NumberFormatException e) { 263 return false; 264 } catch (BufferUnderflowException e) { 265 return false; 266 } finally { 267 if (inStream != null) { 268 try { 269 inStream.close(); 270 } catch (IOException e) { 271 // do nothing 272 } 273 } 274 } 275 } 276 277 /** 278 * Returns a list of file addresses for a given locale, trying relevant methods in order. 279 * 280 * Tries to get binary dictionaries from various sources, in order: 281 * - Uses a content provider to get a public dictionary set, as per the protocol described 282 * in BinaryDictionaryFileDumper. 283 * If that fails: 284 * - Gets a file name from the built-in dictionary for this locale, if any. 285 * If that fails: 286 * - Returns null. 287 * @return The list of addresses of valid dictionary files, or null. 288 */ 289 public static ArrayList<AssetFileAddress> getDictionaryFiles(final Locale locale, 290 final Context context) { 291 292 final boolean hasDefaultWordList = DictionaryFactory.isDictionaryAvailable(context, locale); 293 // TODO: The development-only-diagnostic version is not supported by the Dictionary Pack 294 // Service yet 295 if (!ProductionFlag.USES_DEVELOPMENT_ONLY_DIAGNOSTICS) { 296 // We need internet access to do the following. Only do this if the package actually 297 // has the permission. 298 if (context.checkCallingOrSelfPermission(android.Manifest.permission.INTERNET) 299 == PackageManager.PERMISSION_GRANTED) { 300 BinaryDictionaryFileDumper.cacheWordListsFromContentProvider(locale, context, 301 hasDefaultWordList); 302 } 303 } 304 final File[] cachedWordLists = getCachedWordLists(locale.toString(), context); 305 final String mainDictId = DictionaryInfoUtils.getMainDictId(locale); 306 final DictPackSettings dictPackSettings = new DictPackSettings(context); 307 308 boolean foundMainDict = false; 309 final ArrayList<AssetFileAddress> fileList = CollectionUtils.newArrayList(); 310 // cachedWordLists may not be null, see doc for getCachedDictionaryList 311 for (final File f : cachedWordLists) { 312 final String wordListId = DictionaryInfoUtils.getWordListIdFromFileName(f.getName()); 313 final boolean canUse = f.canRead() && hackCanUseDictionaryFile(locale, f); 314 if (canUse && DictionaryInfoUtils.isMainWordListId(wordListId)) { 315 foundMainDict = true; 316 } 317 if (!dictPackSettings.isWordListActive(wordListId)) continue; 318 if (canUse) { 319 fileList.add(AssetFileAddress.makeFromFileName(f.getPath())); 320 } else { 321 Log.e(TAG, "Found a cached dictionary file but cannot read or use it"); 322 } 323 } 324 325 if (!foundMainDict && dictPackSettings.isWordListActive(mainDictId)) { 326 final int fallbackResId = 327 DictionaryInfoUtils.getMainDictionaryResourceId(context.getResources(), locale); 328 final AssetFileAddress fallbackAsset = loadFallbackResource(context, fallbackResId); 329 if (null != fallbackAsset) { 330 fileList.add(fallbackAsset); 331 } 332 } 333 334 return fileList; 335 } 336 } 337