1/*
2 * Copyright (C) 2011 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License"); you may not
5 * use this file except in compliance with the License. You may obtain a copy of
6 * 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, WITHOUT
12 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13 * License for the specific language governing permissions and limitations under
14 * the License.
15 */
16
17package com.android.inputmethod.latin;
18
19import android.content.ContentResolver;
20import android.content.Context;
21import android.content.res.AssetFileDescriptor;
22import android.database.Cursor;
23import android.net.Uri;
24import android.text.TextUtils;
25import android.util.Log;
26
27import java.io.BufferedInputStream;
28import java.io.File;
29import java.io.FileNotFoundException;
30import java.io.FileOutputStream;
31import java.io.IOException;
32import java.io.InputStream;
33import java.util.Arrays;
34import java.util.Collections;
35import java.util.List;
36import java.util.Locale;
37
38/**
39 * Group class for static methods to help with creation and getting of the binary dictionary
40 * file from the dictionary provider
41 */
42public final class BinaryDictionaryFileDumper {
43    private static final String TAG = BinaryDictionaryFileDumper.class.getSimpleName();
44    private static final boolean DEBUG = false;
45
46    /**
47     * The size of the temporary buffer to copy files.
48     */
49    private static final int FILE_READ_BUFFER_SIZE = 8192;
50    // TODO: make the following data common with the native code
51    private static final byte[] MAGIC_NUMBER_VERSION_1 =
52            new byte[] { (byte)0x78, (byte)0xB1, (byte)0x00, (byte)0x00 };
53    private static final byte[] MAGIC_NUMBER_VERSION_2 =
54            new byte[] { (byte)0x9B, (byte)0xC1, (byte)0x3A, (byte)0xFE };
55
56    private static final String DICTIONARY_PROJECTION[] = { "id" };
57
58    public static final String QUERY_PARAMETER_MAY_PROMPT_USER = "mayPrompt";
59    public static final String QUERY_PARAMETER_TRUE = "true";
60    public static final String QUERY_PARAMETER_DELETE_RESULT = "result";
61    public static final String QUERY_PARAMETER_SUCCESS = "success";
62    public static final String QUERY_PARAMETER_FAILURE = "failure";
63
64    // Prevents this class to be accidentally instantiated.
65    private BinaryDictionaryFileDumper() {
66    }
67
68    /**
69     * Returns a URI builder pointing to the dictionary pack.
70     *
71     * This creates a URI builder able to build a URI pointing to the dictionary
72     * pack content provider for a specific dictionary id.
73     */
74    private static Uri.Builder getProviderUriBuilder(final String path) {
75        return new Uri.Builder().scheme(ContentResolver.SCHEME_CONTENT)
76                .authority(BinaryDictionary.DICTIONARY_PACK_AUTHORITY).appendPath(
77                        path);
78    }
79
80    /**
81     * Queries a content provider for the list of word lists for a specific locale
82     * available to copy into Latin IME.
83     */
84    private static List<WordListInfo> getWordListWordListInfos(final Locale locale,
85            final Context context, final boolean hasDefaultWordList) {
86        final ContentResolver resolver = context.getContentResolver();
87        final Uri.Builder builder = getProviderUriBuilder(locale.toString());
88        if (!hasDefaultWordList) {
89            builder.appendQueryParameter(QUERY_PARAMETER_MAY_PROMPT_USER, QUERY_PARAMETER_TRUE);
90        }
91        final Uri dictionaryPackUri = builder.build();
92
93        final Cursor c = resolver.query(dictionaryPackUri, DICTIONARY_PROJECTION, null, null, null);
94        if (null == c) return Collections.<WordListInfo>emptyList();
95        if (c.getCount() <= 0 || !c.moveToFirst()) {
96            c.close();
97            return Collections.<WordListInfo>emptyList();
98        }
99
100        try {
101            final List<WordListInfo> list = CollectionUtils.newArrayList();
102            do {
103                final String wordListId = c.getString(0);
104                final String wordListLocale = c.getString(1);
105                if (TextUtils.isEmpty(wordListId)) continue;
106                list.add(new WordListInfo(wordListId, wordListLocale));
107            } while (c.moveToNext());
108            c.close();
109            return list;
110        } catch (Exception e) {
111            // Just in case we hit a problem in communication with the dictionary pack.
112            // We don't want to die.
113            Log.e(TAG, "Exception communicating with the dictionary pack : " + e);
114            return Collections.<WordListInfo>emptyList();
115        }
116    }
117
118
119    /**
120     * Helper method to encapsulate exception handling.
121     */
122    private static AssetFileDescriptor openAssetFileDescriptor(final ContentResolver resolver,
123            final Uri uri) {
124        try {
125            return resolver.openAssetFileDescriptor(uri, "r");
126        } catch (FileNotFoundException e) {
127            // I don't want to log the word list URI here for security concerns
128            Log.e(TAG, "Could not find a word list from the dictionary provider.");
129            return null;
130        }
131    }
132
133    /**
134     * Caches a word list the id of which is passed as an argument. This will write the file
135     * to the cache file name designated by its id and locale, overwriting it if already present
136     * and creating it (and its containing directory) if necessary.
137     */
138    private static AssetFileAddress cacheWordList(final String id, final String locale,
139            final ContentResolver resolver, final Context context) {
140
141        final int COMPRESSED_CRYPTED_COMPRESSED = 0;
142        final int CRYPTED_COMPRESSED = 1;
143        final int COMPRESSED_CRYPTED = 2;
144        final int COMPRESSED_ONLY = 3;
145        final int CRYPTED_ONLY = 4;
146        final int NONE = 5;
147        final int MODE_MIN = COMPRESSED_CRYPTED_COMPRESSED;
148        final int MODE_MAX = NONE;
149
150        final Uri.Builder wordListUriBuilder = getProviderUriBuilder(id);
151        final String finalFileName = BinaryDictionaryGetter.getCacheFileName(id, locale, context);
152        final String tempFileName = BinaryDictionaryGetter.getTempFileName(id, context);
153
154        for (int mode = MODE_MIN; mode <= MODE_MAX; ++mode) {
155            InputStream originalSourceStream = null;
156            InputStream inputStream = null;
157            InputStream uncompressedStream = null;
158            InputStream decryptedStream = null;
159            BufferedInputStream bufferedStream = null;
160            File outputFile = null;
161            FileOutputStream outputStream = null;
162            AssetFileDescriptor afd = null;
163            final Uri wordListUri = wordListUriBuilder.build();
164            try {
165                // Open input.
166                afd = openAssetFileDescriptor(resolver, wordListUri);
167                // If we can't open it at all, don't even try a number of times.
168                if (null == afd) return null;
169                originalSourceStream = afd.createInputStream();
170                // Open output.
171                outputFile = new File(tempFileName);
172                // Just to be sure, delete the file. This may fail silently, and return false: this
173                // is the right thing to do, as we just want to continue anyway.
174                outputFile.delete();
175                outputStream = new FileOutputStream(outputFile);
176                // Get the appropriate decryption method for this try
177                switch (mode) {
178                    case COMPRESSED_CRYPTED_COMPRESSED:
179                        uncompressedStream =
180                                FileTransforms.getUncompressedStream(originalSourceStream);
181                        decryptedStream = FileTransforms.getDecryptedStream(uncompressedStream);
182                        inputStream = FileTransforms.getUncompressedStream(decryptedStream);
183                        break;
184                    case CRYPTED_COMPRESSED:
185                        decryptedStream = FileTransforms.getDecryptedStream(originalSourceStream);
186                        inputStream = FileTransforms.getUncompressedStream(decryptedStream);
187                        break;
188                    case COMPRESSED_CRYPTED:
189                        uncompressedStream =
190                                FileTransforms.getUncompressedStream(originalSourceStream);
191                        inputStream = FileTransforms.getDecryptedStream(uncompressedStream);
192                        break;
193                    case COMPRESSED_ONLY:
194                        inputStream = FileTransforms.getUncompressedStream(originalSourceStream);
195                        break;
196                    case CRYPTED_ONLY:
197                        inputStream = FileTransforms.getDecryptedStream(originalSourceStream);
198                        break;
199                    case NONE:
200                        inputStream = originalSourceStream;
201                        break;
202                }
203                bufferedStream = new BufferedInputStream(inputStream);
204                checkMagicAndCopyFileTo(bufferedStream, outputStream);
205                outputStream.flush();
206                outputStream.close();
207                final File finalFile = new File(finalFileName);
208                finalFile.delete();
209                if (!outputFile.renameTo(finalFile)) {
210                    throw new IOException("Can't move the file to its final name");
211                }
212                wordListUriBuilder.appendQueryParameter(QUERY_PARAMETER_DELETE_RESULT,
213                        QUERY_PARAMETER_SUCCESS);
214                if (0 >= resolver.delete(wordListUriBuilder.build(), null, null)) {
215                    Log.e(TAG, "Could not have the dictionary pack delete a word list");
216                }
217                BinaryDictionaryGetter.removeFilesWithIdExcept(context, id, finalFile);
218                // Success! Close files (through the finally{} clause) and return.
219                return AssetFileAddress.makeFromFileName(finalFileName);
220            } catch (Exception e) {
221                if (DEBUG) {
222                    Log.i(TAG, "Can't open word list in mode " + mode + " : " + e);
223                }
224                if (null != outputFile) {
225                    // This may or may not fail. The file may not have been created if the
226                    // exception was thrown before it could be. Hence, both failure and
227                    // success are expected outcomes, so we don't check the return value.
228                    outputFile.delete();
229                }
230                // Try the next method.
231            } finally {
232                // Ignore exceptions while closing files.
233                try {
234                    // inputStream.close() will close afd, we should not call afd.close().
235                    if (null != inputStream) inputStream.close();
236                    if (null != uncompressedStream) uncompressedStream.close();
237                    if (null != decryptedStream) decryptedStream.close();
238                    if (null != bufferedStream) bufferedStream.close();
239                } catch (Exception e) {
240                    Log.e(TAG, "Exception while closing a file descriptor : " + e);
241                }
242                try {
243                    if (null != outputStream) outputStream.close();
244                } catch (Exception e) {
245                    Log.e(TAG, "Exception while closing a file : " + e);
246                }
247            }
248        }
249
250        // We could not copy the file at all. This is very unexpected.
251        // I'd rather not print the word list ID to the log out of security concerns
252        Log.e(TAG, "Could not copy a word list. Will not be able to use it.");
253        // If we can't copy it we should warn the dictionary provider so that it can mark it
254        // as invalid.
255        wordListUriBuilder.appendQueryParameter(QUERY_PARAMETER_DELETE_RESULT,
256                QUERY_PARAMETER_FAILURE);
257        if (0 >= resolver.delete(wordListUriBuilder.build(), null, null)) {
258            Log.e(TAG, "In addition, we were unable to delete it.");
259        }
260        return null;
261    }
262
263    /**
264     * Queries a content provider for word list data for some locale and cache the returned files
265     *
266     * This will query a content provider for word list data for a given locale, and copy the
267     * files locally so that they can be mmap'ed. This may overwrite previously cached word lists
268     * with newer versions if a newer version is made available by the content provider.
269     * @returns the addresses of the word list files, or null if no data could be obtained.
270     * @throw FileNotFoundException if the provider returns non-existent data.
271     * @throw IOException if the provider-returned data could not be read.
272     */
273    public static List<AssetFileAddress> cacheWordListsFromContentProvider(final Locale locale,
274            final Context context, final boolean hasDefaultWordList) {
275        final ContentResolver resolver = context.getContentResolver();
276        final List<WordListInfo> idList = getWordListWordListInfos(locale, context,
277                hasDefaultWordList);
278        final List<AssetFileAddress> fileAddressList = CollectionUtils.newArrayList();
279        for (WordListInfo id : idList) {
280            final AssetFileAddress afd = cacheWordList(id.mId, id.mLocale, resolver, context);
281            if (null != afd) {
282                fileAddressList.add(afd);
283            }
284        }
285        return fileAddressList;
286    }
287
288    /**
289     * Copies the data in an input stream to a target file if the magic number matches.
290     *
291     * If the magic number does not match the expected value, this method throws an
292     * IOException. Other usual conditions for IOException or FileNotFoundException
293     * also apply.
294     *
295     * @param input the stream to be copied.
296     * @param output an output stream to copy the data to.
297     */
298    // TODO: make output a BufferedOutputStream
299    private static void checkMagicAndCopyFileTo(final BufferedInputStream input,
300            final FileOutputStream output) throws FileNotFoundException, IOException {
301        // Check the magic number
302        final int length = MAGIC_NUMBER_VERSION_2.length;
303        final byte[] magicNumberBuffer = new byte[length];
304        final int readMagicNumberSize = input.read(magicNumberBuffer, 0, length);
305        if (readMagicNumberSize < length) {
306            throw new IOException("Less bytes to read than the magic number length");
307        }
308        if (!Arrays.equals(MAGIC_NUMBER_VERSION_2, magicNumberBuffer)) {
309            if (!Arrays.equals(MAGIC_NUMBER_VERSION_1, magicNumberBuffer)) {
310                throw new IOException("Wrong magic number for downloaded file");
311            }
312        }
313        output.write(magicNumberBuffer);
314
315        // Actually copy the file
316        final byte[] buffer = new byte[FILE_READ_BUFFER_SIZE];
317        for (int readBytes = input.read(buffer); readBytes >= 0; readBytes = input.read(buffer))
318            output.write(buffer, 0, readBytes);
319        input.close();
320    }
321}
322