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