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.ContentProviderClient; 20 import android.content.ContentResolver; 21 import android.content.ContentValues; 22 import android.content.Context; 23 import android.content.res.AssetFileDescriptor; 24 import android.database.Cursor; 25 import android.net.Uri; 26 import android.os.RemoteException; 27 import android.text.TextUtils; 28 import android.util.Log; 29 30 import com.android.inputmethod.dictionarypack.DictionaryPackConstants; 31 import com.android.inputmethod.dictionarypack.MD5Calculator; 32 import com.android.inputmethod.latin.utils.DictionaryInfoUtils; 33 import com.android.inputmethod.latin.utils.DictionaryInfoUtils.DictionaryInfo; 34 import com.android.inputmethod.latin.utils.FileTransforms; 35 import com.android.inputmethod.latin.utils.MetadataFileUriGetter; 36 37 import java.io.BufferedInputStream; 38 import java.io.BufferedOutputStream; 39 import java.io.Closeable; 40 import java.io.File; 41 import java.io.FileInputStream; 42 import java.io.FileNotFoundException; 43 import java.io.FileOutputStream; 44 import java.io.IOException; 45 import java.io.InputStream; 46 import java.util.ArrayList; 47 import java.util.Arrays; 48 import java.util.Collections; 49 import java.util.List; 50 import java.util.Locale; 51 52 /** 53 * Group class for static methods to help with creation and getting of the binary dictionary 54 * file from the dictionary provider 55 */ 56 public final class BinaryDictionaryFileDumper { 57 private static final String TAG = BinaryDictionaryFileDumper.class.getSimpleName(); 58 private static final boolean DEBUG = false; 59 60 /** 61 * The size of the temporary buffer to copy files. 62 */ 63 private static final int FILE_READ_BUFFER_SIZE = 8192; 64 // TODO: make the following data common with the native code 65 private static final byte[] MAGIC_NUMBER_VERSION_1 = 66 new byte[] { (byte)0x78, (byte)0xB1, (byte)0x00, (byte)0x00 }; 67 private static final byte[] MAGIC_NUMBER_VERSION_2 = 68 new byte[] { (byte)0x9B, (byte)0xC1, (byte)0x3A, (byte)0xFE }; 69 70 private static final String DICTIONARY_PROJECTION[] = { "id" }; 71 72 private static final String QUERY_PARAMETER_MAY_PROMPT_USER = "mayPrompt"; 73 private static final String QUERY_PARAMETER_TRUE = "true"; 74 private static final String QUERY_PARAMETER_DELETE_RESULT = "result"; 75 private static final String QUERY_PARAMETER_SUCCESS = "success"; 76 private static final String QUERY_PARAMETER_FAILURE = "failure"; 77 78 // Using protocol version 2 to communicate with the dictionary pack 79 private static final String QUERY_PARAMETER_PROTOCOL = "protocol"; 80 private static final String QUERY_PARAMETER_PROTOCOL_VALUE = "2"; 81 82 // The path fragment to append after the client ID for dictionary info requests. 83 private static final String QUERY_PATH_DICT_INFO = "dict"; 84 // The path fragment to append after the client ID for dictionary datafile requests. 85 private static final String QUERY_PATH_DATAFILE = "datafile"; 86 // The path fragment to append after the client ID for updating the metadata URI. 87 private static final String QUERY_PATH_METADATA = "metadata"; 88 private static final String INSERT_METADATA_CLIENT_ID_COLUMN = "clientid"; 89 private static final String INSERT_METADATA_METADATA_URI_COLUMN = "uri"; 90 private static final String INSERT_METADATA_METADATA_ADDITIONAL_ID_COLUMN = "additionalid"; 91 92 // Prevents this class to be accidentally instantiated. BinaryDictionaryFileDumper()93 private BinaryDictionaryFileDumper() { 94 } 95 96 /** 97 * Returns a URI builder pointing to the dictionary pack. 98 * 99 * This creates a URI builder able to build a URI pointing to the dictionary 100 * pack content provider for a specific dictionary id. 101 */ getProviderUriBuilder(final String path)102 public static Uri.Builder getProviderUriBuilder(final String path) { 103 return new Uri.Builder().scheme(ContentResolver.SCHEME_CONTENT) 104 .authority(DictionaryPackConstants.AUTHORITY).appendPath(path); 105 } 106 107 /** 108 * Gets the content URI builder for a specified type. 109 * 110 * Supported types include QUERY_PATH_DICT_INFO, which takes the locale as 111 * the extraPath argument, and QUERY_PATH_DATAFILE, which needs a wordlist ID 112 * as the extraPath argument. 113 * 114 * @param clientId the clientId to use 115 * @param contentProviderClient the instance of content provider client 116 * @param queryPathType the path element encoding the type 117 * @param extraPath optional extra argument for this type (typically word list id) 118 * @return a builder that can build the URI for the best supported protocol version 119 * @throws RemoteException if the client can't be contacted 120 */ getContentUriBuilderForType(final String clientId, final ContentProviderClient contentProviderClient, final String queryPathType, final String extraPath)121 private static Uri.Builder getContentUriBuilderForType(final String clientId, 122 final ContentProviderClient contentProviderClient, final String queryPathType, 123 final String extraPath) throws RemoteException { 124 // Check whether protocol v2 is supported by building a v2 URI and calling getType() 125 // on it. If this returns null, v2 is not supported. 126 final Uri.Builder uriV2Builder = getProviderUriBuilder(clientId); 127 uriV2Builder.appendPath(queryPathType); 128 uriV2Builder.appendPath(extraPath); 129 uriV2Builder.appendQueryParameter(QUERY_PARAMETER_PROTOCOL, 130 QUERY_PARAMETER_PROTOCOL_VALUE); 131 if (null != contentProviderClient.getType(uriV2Builder.build())) return uriV2Builder; 132 // Protocol v2 is not supported, so create and return the protocol v1 uri. 133 return getProviderUriBuilder(extraPath); 134 } 135 136 /** 137 * Queries a content provider for the list of word lists for a specific locale 138 * available to copy into Latin IME. 139 */ getWordListWordListInfos(final Locale locale, final Context context, final boolean hasDefaultWordList)140 private static List<WordListInfo> getWordListWordListInfos(final Locale locale, 141 final Context context, final boolean hasDefaultWordList) { 142 final String clientId = context.getString(R.string.dictionary_pack_client_id); 143 final ContentProviderClient client = context.getContentResolver(). 144 acquireContentProviderClient(getProviderUriBuilder("").build()); 145 if (null == client) return Collections.<WordListInfo>emptyList(); 146 Cursor cursor = null; 147 try { 148 final Uri.Builder builder = getContentUriBuilderForType(clientId, client, 149 QUERY_PATH_DICT_INFO, locale.toString()); 150 if (!hasDefaultWordList) { 151 builder.appendQueryParameter(QUERY_PARAMETER_MAY_PROMPT_USER, 152 QUERY_PARAMETER_TRUE); 153 } 154 final Uri queryUri = builder.build(); 155 final boolean isProtocolV2 = (QUERY_PARAMETER_PROTOCOL_VALUE.equals( 156 queryUri.getQueryParameter(QUERY_PARAMETER_PROTOCOL))); 157 158 cursor = client.query(queryUri, DICTIONARY_PROJECTION, null, null, null); 159 if (isProtocolV2 && null == cursor) { 160 reinitializeClientRecordInDictionaryContentProvider(context, client, clientId); 161 cursor = client.query(queryUri, DICTIONARY_PROJECTION, null, null, null); 162 } 163 if (null == cursor) return Collections.<WordListInfo>emptyList(); 164 if (cursor.getCount() <= 0 || !cursor.moveToFirst()) { 165 return Collections.<WordListInfo>emptyList(); 166 } 167 final ArrayList<WordListInfo> list = new ArrayList<>(); 168 do { 169 final String wordListId = cursor.getString(0); 170 final String wordListLocale = cursor.getString(1); 171 final String wordListRawChecksum = cursor.getString(2); 172 if (TextUtils.isEmpty(wordListId)) continue; 173 list.add(new WordListInfo(wordListId, wordListLocale, wordListRawChecksum)); 174 } while (cursor.moveToNext()); 175 return list; 176 } catch (RemoteException e) { 177 // The documentation is unclear as to in which cases this may happen, but it probably 178 // happens when the content provider got suddenly killed because it crashed or because 179 // the user disabled it through Settings. 180 Log.e(TAG, "RemoteException: communication with the dictionary pack cut", e); 181 return Collections.<WordListInfo>emptyList(); 182 } catch (Exception e) { 183 // A crash here is dangerous because crashing here would brick any encrypted device - 184 // we need the keyboard to be up and working to enter the password, so we don't want 185 // to die no matter what. So let's be as safe as possible. 186 Log.e(TAG, "Unexpected exception communicating with the dictionary pack", e); 187 return Collections.<WordListInfo>emptyList(); 188 } finally { 189 if (null != cursor) { 190 cursor.close(); 191 } 192 client.release(); 193 } 194 } 195 196 197 /** 198 * Helper method to encapsulate exception handling. 199 */ openAssetFileDescriptor( final ContentProviderClient providerClient, final Uri uri)200 private static AssetFileDescriptor openAssetFileDescriptor( 201 final ContentProviderClient providerClient, final Uri uri) { 202 try { 203 return providerClient.openAssetFile(uri, "r"); 204 } catch (FileNotFoundException e) { 205 // I don't want to log the word list URI here for security concerns. The exception 206 // contains the name of the file, so let's not pass it to Log.e here. 207 Log.e(TAG, "Could not find a word list from the dictionary provider." 208 /* intentionally don't pass the exception (see comment above) */); 209 return null; 210 } catch (RemoteException e) { 211 Log.e(TAG, "Can't communicate with the dictionary pack", e); 212 return null; 213 } 214 } 215 216 /** 217 * Caches a word list the id of which is passed as an argument. This will write the file 218 * to the cache file name designated by its id and locale, overwriting it if already present 219 * and creating it (and its containing directory) if necessary. 220 */ cacheWordList(final String wordlistId, final String locale, final String rawChecksum, final ContentProviderClient providerClient, final Context context)221 private static void cacheWordList(final String wordlistId, final String locale, 222 final String rawChecksum, final ContentProviderClient providerClient, 223 final Context context) { 224 final int COMPRESSED_CRYPTED_COMPRESSED = 0; 225 final int CRYPTED_COMPRESSED = 1; 226 final int COMPRESSED_CRYPTED = 2; 227 final int COMPRESSED_ONLY = 3; 228 final int CRYPTED_ONLY = 4; 229 final int NONE = 5; 230 final int MODE_MIN = COMPRESSED_CRYPTED_COMPRESSED; 231 final int MODE_MAX = NONE; 232 233 final String clientId = context.getString(R.string.dictionary_pack_client_id); 234 final Uri.Builder wordListUriBuilder; 235 try { 236 wordListUriBuilder = getContentUriBuilderForType(clientId, 237 providerClient, QUERY_PATH_DATAFILE, wordlistId /* extraPath */); 238 } catch (RemoteException e) { 239 Log.e(TAG, "Can't communicate with the dictionary pack", e); 240 return; 241 } 242 final String finalFileName = 243 DictionaryInfoUtils.getCacheFileName(wordlistId, locale, context); 244 String tempFileName; 245 try { 246 tempFileName = BinaryDictionaryGetter.getTempFileName(wordlistId, context); 247 } catch (IOException e) { 248 Log.e(TAG, "Can't open the temporary file", e); 249 return; 250 } 251 252 for (int mode = MODE_MIN; mode <= MODE_MAX; ++mode) { 253 final InputStream originalSourceStream; 254 InputStream inputStream = null; 255 InputStream uncompressedStream = null; 256 InputStream decryptedStream = null; 257 BufferedInputStream bufferedInputStream = null; 258 File outputFile = null; 259 BufferedOutputStream bufferedOutputStream = null; 260 AssetFileDescriptor afd = null; 261 final Uri wordListUri = wordListUriBuilder.build(); 262 try { 263 // Open input. 264 afd = openAssetFileDescriptor(providerClient, wordListUri); 265 // If we can't open it at all, don't even try a number of times. 266 if (null == afd) return; 267 originalSourceStream = afd.createInputStream(); 268 // Open output. 269 outputFile = new File(tempFileName); 270 // Just to be sure, delete the file. This may fail silently, and return false: this 271 // is the right thing to do, as we just want to continue anyway. 272 outputFile.delete(); 273 // Get the appropriate decryption method for this try 274 switch (mode) { 275 case COMPRESSED_CRYPTED_COMPRESSED: 276 uncompressedStream = 277 FileTransforms.getUncompressedStream(originalSourceStream); 278 decryptedStream = FileTransforms.getDecryptedStream(uncompressedStream); 279 inputStream = FileTransforms.getUncompressedStream(decryptedStream); 280 break; 281 case CRYPTED_COMPRESSED: 282 decryptedStream = FileTransforms.getDecryptedStream(originalSourceStream); 283 inputStream = FileTransforms.getUncompressedStream(decryptedStream); 284 break; 285 case COMPRESSED_CRYPTED: 286 uncompressedStream = 287 FileTransforms.getUncompressedStream(originalSourceStream); 288 inputStream = FileTransforms.getDecryptedStream(uncompressedStream); 289 break; 290 case COMPRESSED_ONLY: 291 inputStream = FileTransforms.getUncompressedStream(originalSourceStream); 292 break; 293 case CRYPTED_ONLY: 294 inputStream = FileTransforms.getDecryptedStream(originalSourceStream); 295 break; 296 case NONE: 297 inputStream = originalSourceStream; 298 break; 299 } 300 bufferedInputStream = new BufferedInputStream(inputStream); 301 bufferedOutputStream = new BufferedOutputStream(new FileOutputStream(outputFile)); 302 checkMagicAndCopyFileTo(bufferedInputStream, bufferedOutputStream); 303 bufferedOutputStream.flush(); 304 bufferedOutputStream.close(); 305 final String actualRawChecksum = MD5Calculator.checksum( 306 new BufferedInputStream(new FileInputStream(outputFile))); 307 Log.i(TAG, "Computed checksum for downloaded dictionary. Expected = " + rawChecksum 308 + " ; actual = " + actualRawChecksum); 309 if (!TextUtils.isEmpty(rawChecksum) && !rawChecksum.equals(actualRawChecksum)) { 310 throw new IOException("Could not decode the file correctly : checksum differs"); 311 } 312 final File finalFile = new File(finalFileName); 313 finalFile.delete(); 314 if (!outputFile.renameTo(finalFile)) { 315 throw new IOException("Can't move the file to its final name"); 316 } 317 wordListUriBuilder.appendQueryParameter(QUERY_PARAMETER_DELETE_RESULT, 318 QUERY_PARAMETER_SUCCESS); 319 if (0 >= providerClient.delete(wordListUriBuilder.build(), null, null)) { 320 Log.e(TAG, "Could not have the dictionary pack delete a word list"); 321 } 322 BinaryDictionaryGetter.removeFilesWithIdExcept(context, wordlistId, finalFile); 323 Log.e(TAG, "Successfully copied file for wordlist ID " + wordlistId); 324 // Success! Close files (through the finally{} clause) and return. 325 return; 326 } catch (Exception e) { 327 if (DEBUG) { 328 Log.i(TAG, "Can't open word list in mode " + mode, e); 329 } 330 if (null != outputFile) { 331 // This may or may not fail. The file may not have been created if the 332 // exception was thrown before it could be. Hence, both failure and 333 // success are expected outcomes, so we don't check the return value. 334 outputFile.delete(); 335 } 336 // Try the next method. 337 } finally { 338 // Ignore exceptions while closing files. 339 closeAssetFileDescriptorAndReportAnyException(afd); 340 closeCloseableAndReportAnyException(inputStream); 341 closeCloseableAndReportAnyException(uncompressedStream); 342 closeCloseableAndReportAnyException(decryptedStream); 343 closeCloseableAndReportAnyException(bufferedInputStream); 344 closeCloseableAndReportAnyException(bufferedOutputStream); 345 } 346 } 347 348 // We could not copy the file at all. This is very unexpected. 349 // I'd rather not print the word list ID to the log out of security concerns 350 Log.e(TAG, "Could not copy a word list. Will not be able to use it."); 351 // If we can't copy it we should warn the dictionary provider so that it can mark it 352 // as invalid. 353 reportBrokenFileToDictionaryProvider(providerClient, clientId, wordlistId); 354 } 355 reportBrokenFileToDictionaryProvider( final ContentProviderClient providerClient, final String clientId, final String wordlistId)356 public static boolean reportBrokenFileToDictionaryProvider( 357 final ContentProviderClient providerClient, final String clientId, 358 final String wordlistId) { 359 try { 360 final Uri.Builder wordListUriBuilder = getContentUriBuilderForType(clientId, 361 providerClient, QUERY_PATH_DATAFILE, wordlistId /* extraPath */); 362 wordListUriBuilder.appendQueryParameter(QUERY_PARAMETER_DELETE_RESULT, 363 QUERY_PARAMETER_FAILURE); 364 if (0 >= providerClient.delete(wordListUriBuilder.build(), null, null)) { 365 Log.e(TAG, "Unable to delete a word list."); 366 } 367 } catch (RemoteException e) { 368 Log.e(TAG, "Communication with the dictionary provider was cut", e); 369 return false; 370 } 371 return true; 372 } 373 374 // Ideally the two following methods should be merged, but AssetFileDescriptor does not 375 // implement Closeable although it does implement #close(), and Java does not have 376 // structural typing. closeAssetFileDescriptorAndReportAnyException( final AssetFileDescriptor file)377 private static void closeAssetFileDescriptorAndReportAnyException( 378 final AssetFileDescriptor file) { 379 try { 380 if (null != file) file.close(); 381 } catch (Exception e) { 382 Log.e(TAG, "Exception while closing a file", e); 383 } 384 } 385 closeCloseableAndReportAnyException(final Closeable file)386 private static void closeCloseableAndReportAnyException(final Closeable file) { 387 try { 388 if (null != file) file.close(); 389 } catch (Exception e) { 390 Log.e(TAG, "Exception while closing a file", e); 391 } 392 } 393 394 /** 395 * Queries a content provider for word list data for some locale and cache the returned files 396 * 397 * This will query a content provider for word list data for a given locale, and copy the 398 * files locally so that they can be mmap'ed. This may overwrite previously cached word lists 399 * with newer versions if a newer version is made available by the content provider. 400 * @throw FileNotFoundException if the provider returns non-existent data. 401 * @throw IOException if the provider-returned data could not be read. 402 */ cacheWordListsFromContentProvider(final Locale locale, final Context context, final boolean hasDefaultWordList)403 public static void cacheWordListsFromContentProvider(final Locale locale, 404 final Context context, final boolean hasDefaultWordList) { 405 final ContentProviderClient providerClient; 406 try { 407 providerClient = context.getContentResolver(). 408 acquireContentProviderClient(getProviderUriBuilder("").build()); 409 } catch (final SecurityException e) { 410 Log.e(TAG, "No permission to communicate with the dictionary provider", e); 411 return; 412 } 413 if (null == providerClient) { 414 Log.e(TAG, "Can't establish communication with the dictionary provider"); 415 return; 416 } 417 try { 418 final List<WordListInfo> idList = getWordListWordListInfos(locale, context, 419 hasDefaultWordList); 420 for (WordListInfo id : idList) { 421 cacheWordList(id.mId, id.mLocale, id.mRawChecksum, providerClient, context); 422 } 423 } finally { 424 providerClient.release(); 425 } 426 } 427 428 /** 429 * Copies the data in an input stream to a target file if the magic number matches. 430 * 431 * If the magic number does not match the expected value, this method throws an 432 * IOException. Other usual conditions for IOException or FileNotFoundException 433 * also apply. 434 * 435 * @param input the stream to be copied. 436 * @param output an output stream to copy the data to. 437 */ checkMagicAndCopyFileTo(final BufferedInputStream input, final BufferedOutputStream output)438 public static void checkMagicAndCopyFileTo(final BufferedInputStream input, 439 final BufferedOutputStream output) throws FileNotFoundException, IOException { 440 // Check the magic number 441 final int length = MAGIC_NUMBER_VERSION_2.length; 442 final byte[] magicNumberBuffer = new byte[length]; 443 final int readMagicNumberSize = input.read(magicNumberBuffer, 0, length); 444 if (readMagicNumberSize < length) { 445 throw new IOException("Less bytes to read than the magic number length"); 446 } 447 if (!Arrays.equals(MAGIC_NUMBER_VERSION_2, magicNumberBuffer)) { 448 if (!Arrays.equals(MAGIC_NUMBER_VERSION_1, magicNumberBuffer)) { 449 throw new IOException("Wrong magic number for downloaded file"); 450 } 451 } 452 output.write(magicNumberBuffer); 453 454 // Actually copy the file 455 final byte[] buffer = new byte[FILE_READ_BUFFER_SIZE]; 456 for (int readBytes = input.read(buffer); readBytes >= 0; readBytes = input.read(buffer)) { 457 output.write(buffer, 0, readBytes); 458 } 459 input.close(); 460 } 461 reinitializeClientRecordInDictionaryContentProvider(final Context context, final ContentProviderClient client, final String clientId)462 private static void reinitializeClientRecordInDictionaryContentProvider(final Context context, 463 final ContentProviderClient client, final String clientId) throws RemoteException { 464 final String metadataFileUri = MetadataFileUriGetter.getMetadataUri(context); 465 final String metadataAdditionalId = MetadataFileUriGetter.getMetadataAdditionalId(context); 466 // Tell the content provider to reset all information about this client id 467 final Uri metadataContentUri = getProviderUriBuilder(clientId) 468 .appendPath(QUERY_PATH_METADATA) 469 .appendQueryParameter(QUERY_PARAMETER_PROTOCOL, QUERY_PARAMETER_PROTOCOL_VALUE) 470 .build(); 471 client.delete(metadataContentUri, null, null); 472 // Update the metadata URI 473 final ContentValues metadataValues = new ContentValues(); 474 metadataValues.put(INSERT_METADATA_CLIENT_ID_COLUMN, clientId); 475 metadataValues.put(INSERT_METADATA_METADATA_URI_COLUMN, metadataFileUri); 476 metadataValues.put(INSERT_METADATA_METADATA_ADDITIONAL_ID_COLUMN, metadataAdditionalId); 477 client.insert(metadataContentUri, metadataValues); 478 479 // Update the dictionary list. 480 final Uri dictionaryContentUriBase = getProviderUriBuilder(clientId) 481 .appendPath(QUERY_PATH_DICT_INFO) 482 .appendQueryParameter(QUERY_PARAMETER_PROTOCOL, QUERY_PARAMETER_PROTOCOL_VALUE) 483 .build(); 484 final ArrayList<DictionaryInfo> dictionaryList = 485 DictionaryInfoUtils.getCurrentDictionaryFileNameAndVersionInfo(context); 486 final int length = dictionaryList.size(); 487 for (int i = 0; i < length; ++i) { 488 final DictionaryInfo info = dictionaryList.get(i); 489 client.insert(Uri.withAppendedPath(dictionaryContentUriBase, info.mId), 490 info.toContentValues()); 491 } 492 } 493 494 /** 495 * Initialize a client record with the dictionary content provider. 496 * 497 * This merely acquires the content provider and calls 498 * #reinitializeClientRecordInDictionaryContentProvider. 499 * 500 * @param context the context for resources and providers. 501 * @param clientId the client ID to use. 502 */ initializeClientRecordHelper(final Context context, final String clientId)503 public static void initializeClientRecordHelper(final Context context, final String clientId) { 504 try { 505 final ContentProviderClient client = context.getContentResolver(). 506 acquireContentProviderClient(getProviderUriBuilder("").build()); 507 if (null == client) return; 508 reinitializeClientRecordInDictionaryContentProvider(context, client, clientId); 509 } catch (RemoteException e) { 510 Log.e(TAG, "Cannot contact the dictionary content provider", e); 511 } 512 } 513 } 514