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