DictionaryProvider.java revision 267e528253801423768a1197b0c7d76e073ee9ab
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.dictionarypack;
18
19import android.content.ContentProvider;
20import android.content.ContentResolver;
21import android.content.ContentValues;
22import android.content.Context;
23import android.content.UriMatcher;
24import android.content.res.AssetFileDescriptor;
25import android.database.AbstractCursor;
26import android.database.Cursor;
27import android.database.sqlite.SQLiteDatabase;
28import android.net.Uri;
29import android.os.ParcelFileDescriptor;
30import android.text.TextUtils;
31import android.util.Log;
32
33import com.android.inputmethod.latin.R;
34import com.android.inputmethod.latin.utils.DebugLogUtils;
35
36import java.io.File;
37import java.io.FileNotFoundException;
38import java.util.Collection;
39import java.util.Collections;
40import java.util.HashMap;
41
42/**
43 * Provider for dictionaries.
44 *
45 * This class is a ContentProvider exposing all available dictionary data as managed by
46 * the dictionary pack.
47 */
48public final class DictionaryProvider extends ContentProvider {
49    private static final String TAG = DictionaryProvider.class.getSimpleName();
50    public static final boolean DEBUG = false;
51
52    public static final Uri CONTENT_URI =
53            Uri.parse(ContentResolver.SCHEME_CONTENT + "://" + DictionaryPackConstants.AUTHORITY);
54    private static final String QUERY_PARAMETER_MAY_PROMPT_USER = "mayPrompt";
55    private static final String QUERY_PARAMETER_TRUE = "true";
56    private static final String QUERY_PARAMETER_DELETE_RESULT = "result";
57    private static final String QUERY_PARAMETER_FAILURE = "failure";
58    public static final String QUERY_PARAMETER_PROTOCOL_VERSION = "protocol";
59    private static final int NO_MATCH = 0;
60    private static final int DICTIONARY_V1_WHOLE_LIST = 1;
61    private static final int DICTIONARY_V1_DICT_INFO = 2;
62    private static final int DICTIONARY_V2_METADATA = 3;
63    private static final int DICTIONARY_V2_WHOLE_LIST = 4;
64    private static final int DICTIONARY_V2_DICT_INFO = 5;
65    private static final int DICTIONARY_V2_DATAFILE = 6;
66    private static final UriMatcher sUriMatcherV1 = new UriMatcher(NO_MATCH);
67    private static final UriMatcher sUriMatcherV2 = new UriMatcher(NO_MATCH);
68    static
69    {
70        sUriMatcherV1.addURI(DictionaryPackConstants.AUTHORITY, "list", DICTIONARY_V1_WHOLE_LIST);
71        sUriMatcherV1.addURI(DictionaryPackConstants.AUTHORITY, "*", DICTIONARY_V1_DICT_INFO);
72        sUriMatcherV2.addURI(DictionaryPackConstants.AUTHORITY, "*/metadata",
73                DICTIONARY_V2_METADATA);
74        sUriMatcherV2.addURI(DictionaryPackConstants.AUTHORITY, "*/list", DICTIONARY_V2_WHOLE_LIST);
75        sUriMatcherV2.addURI(DictionaryPackConstants.AUTHORITY, "*/dict/*",
76                DICTIONARY_V2_DICT_INFO);
77        sUriMatcherV2.addURI(DictionaryPackConstants.AUTHORITY, "*/datafile/*",
78                DICTIONARY_V2_DATAFILE);
79    }
80
81    // MIME types for dictionary and dictionary list, as required by ContentProvider contract.
82    public static final String DICT_LIST_MIME_TYPE =
83            "vnd.android.cursor.item/vnd.google.dictionarylist";
84    public static final String DICT_DATAFILE_MIME_TYPE =
85            "vnd.android.cursor.item/vnd.google.dictionary";
86
87    public static final String ID_CATEGORY_SEPARATOR = ":";
88
89    private static final class WordListInfo {
90        public final String mId;
91        public final String mLocale;
92        public final String mRawChecksum;
93        public final int mMatchLevel;
94        public WordListInfo(final String id, final String locale, final String rawChecksum,
95                final int matchLevel) {
96            mId = id;
97            mLocale = locale;
98            mRawChecksum = rawChecksum;
99            mMatchLevel = matchLevel;
100        }
101    }
102
103    /**
104     * A cursor for returning a list of file ids from a List of strings.
105     *
106     * This simulates only the necessary methods. It has no error handling to speak of,
107     * and does not support everything a database does, only a few select necessary methods.
108     */
109    private static final class ResourcePathCursor extends AbstractCursor {
110
111        // Column names for the cursor returned by this content provider.
112        static private final String[] columnNames = { MetadataDbHelper.WORDLISTID_COLUMN,
113                MetadataDbHelper.LOCALE_COLUMN, MetadataDbHelper.RAW_CHECKSUM_COLUMN };
114
115        // The list of word lists served by this provider that match the client request.
116        final WordListInfo[] mWordLists;
117        // Note : the cursor also uses mPos, which is defined in AbstractCursor.
118
119        public ResourcePathCursor(final Collection<WordListInfo> wordLists) {
120            // Allocating a 0-size WordListInfo here allows the toArray() method
121            // to ensure we have a strongly-typed array. It's thrown out. That's
122            // what the documentation of #toArray says to do in order to get a
123            // new strongly typed array of the correct size.
124            mWordLists = wordLists.toArray(new WordListInfo[0]);
125            mPos = 0;
126        }
127
128        @Override
129        public String[] getColumnNames() {
130            return columnNames;
131        }
132
133        @Override
134        public int getCount() {
135            return mWordLists.length;
136        }
137
138        @Override public double getDouble(int column) { return 0; }
139        @Override public float getFloat(int column) { return 0; }
140        @Override public int getInt(int column) { return 0; }
141        @Override public short getShort(int column) { return 0; }
142        @Override public long getLong(int column) { return 0; }
143
144        @Override public String getString(final int column) {
145            switch (column) {
146                case 0: return mWordLists[mPos].mId;
147                case 1: return mWordLists[mPos].mLocale;
148                case 2: return mWordLists[mPos].mRawChecksum;
149                default : return null;
150            }
151        }
152
153        @Override
154        public boolean isNull(final int column) {
155            if (mPos >= mWordLists.length) return true;
156            return column != 0;
157        }
158    }
159
160    @Override
161    public boolean onCreate() {
162        return true;
163    }
164
165    private static int matchUri(final Uri uri) {
166        int protocolVersion = 1;
167        final String protocolVersionArg = uri.getQueryParameter(QUERY_PARAMETER_PROTOCOL_VERSION);
168        if ("2".equals(protocolVersionArg)) protocolVersion = 2;
169        switch (protocolVersion) {
170            case 1: return sUriMatcherV1.match(uri);
171            case 2: return sUriMatcherV2.match(uri);
172            default: return NO_MATCH;
173        }
174    }
175
176    private static String getClientId(final Uri uri) {
177        int protocolVersion = 1;
178        final String protocolVersionArg = uri.getQueryParameter(QUERY_PARAMETER_PROTOCOL_VERSION);
179        if ("2".equals(protocolVersionArg)) protocolVersion = 2;
180        switch (protocolVersion) {
181            case 1: return null; // In protocol 1, the client ID is always null.
182            case 2: return uri.getPathSegments().get(0);
183            default: return null;
184        }
185    }
186
187    /**
188     * Returns the MIME type of the content associated with an Uri
189     *
190     * @see android.content.ContentProvider#getType(android.net.Uri)
191     *
192     * @param uri the URI of the content the type of which should be returned.
193     * @return the MIME type, or null if the URL is not recognized.
194     */
195    @Override
196    public String getType(final Uri uri) {
197        PrivateLog.log("Asked for type of : " + uri);
198        final int match = matchUri(uri);
199        switch (match) {
200            case NO_MATCH: return null;
201            case DICTIONARY_V1_WHOLE_LIST:
202            case DICTIONARY_V1_DICT_INFO:
203            case DICTIONARY_V2_WHOLE_LIST:
204            case DICTIONARY_V2_DICT_INFO: return DICT_LIST_MIME_TYPE;
205            case DICTIONARY_V2_DATAFILE: return DICT_DATAFILE_MIME_TYPE;
206            default: return null;
207        }
208    }
209
210    /**
211     * Query the provider for dictionary files.
212     *
213     * This version dispatches the query according to the protocol version found in the
214     * ?protocol= query parameter. If absent or not well-formed, it defaults to 1.
215     * @see android.content.ContentProvider#query(Uri, String[], String, String[], String)
216     *
217     * @param uri a content uri (see sUriMatcherV{1,2} at the top of this file for format)
218     * @param projection ignored. All columns are always returned.
219     * @param selection ignored.
220     * @param selectionArgs ignored.
221     * @param sortOrder ignored. The results are always returned in no particular order.
222     * @return a cursor matching the uri, or null if the URI was not recognized.
223     */
224    @Override
225    public Cursor query(final Uri uri, final String[] projection, final String selection,
226            final String[] selectionArgs, final String sortOrder) {
227        DebugLogUtils.l("Uri =", uri);
228        PrivateLog.log("Query : " + uri);
229        final String clientId = getClientId(uri);
230        final int match = matchUri(uri);
231        switch (match) {
232            case DICTIONARY_V1_WHOLE_LIST:
233            case DICTIONARY_V2_WHOLE_LIST:
234                final Cursor c = MetadataDbHelper.queryDictionaries(getContext(), clientId);
235                DebugLogUtils.l("List of dictionaries with count", c.getCount());
236                PrivateLog.log("Returned a list of " + c.getCount() + " items");
237                return c;
238            case DICTIONARY_V2_DICT_INFO:
239                // In protocol version 2, we return null if the client is unknown. Otherwise
240                // we behave exactly like for protocol 1.
241                if (!MetadataDbHelper.isClientKnown(getContext(), clientId)) return null;
242                // Fall through
243            case DICTIONARY_V1_DICT_INFO:
244                final String locale = uri.getLastPathSegment();
245                // If LatinIME does not have a dictionary for this locale at all, it will
246                // send us true for this value. In this case, we may prompt the user for
247                // a decision about downloading a dictionary even over a metered connection.
248                final String mayPromptValue =
249                        uri.getQueryParameter(QUERY_PARAMETER_MAY_PROMPT_USER);
250                final boolean mayPrompt = QUERY_PARAMETER_TRUE.equals(mayPromptValue);
251                final Collection<WordListInfo> dictFiles =
252                        getDictionaryWordListsForLocale(clientId, locale, mayPrompt);
253                // TODO: pass clientId to the following function
254                DictionaryService.updateNowIfNotUpdatedInAVeryLongTime(getContext());
255                if (null != dictFiles && dictFiles.size() > 0) {
256                    PrivateLog.log("Returned " + dictFiles.size() + " files");
257                    return new ResourcePathCursor(dictFiles);
258                } else {
259                    PrivateLog.log("No dictionary files for this URL");
260                    return new ResourcePathCursor(Collections.<WordListInfo>emptyList());
261                }
262            // V2_METADATA and V2_DATAFILE are not supported for query()
263            default:
264                return null;
265        }
266    }
267
268    /**
269     * Helper method to get the wordlist metadata associated with a wordlist ID.
270     *
271     * @param clientId the ID of the client
272     * @param wordlistId the ID of the wordlist for which to get the metadata.
273     * @return the metadata for this wordlist ID, or null if none could be found.
274     */
275    private ContentValues getWordlistMetadataForWordlistId(final String clientId,
276            final String wordlistId) {
277        final Context context = getContext();
278        if (TextUtils.isEmpty(wordlistId)) return null;
279        final SQLiteDatabase db = MetadataDbHelper.getDb(context, clientId);
280        return MetadataDbHelper.getInstalledOrDeletingWordListContentValuesByWordListId(
281                db, wordlistId);
282    }
283
284    /**
285     * Opens an asset file for an URI.
286     *
287     * Called by {@link android.content.ContentResolver#openAssetFileDescriptor(Uri, String)} or
288     * {@link android.content.ContentResolver#openInputStream(Uri)} from a client requesting a
289     * dictionary.
290     * @see android.content.ContentProvider#openAssetFile(Uri, String)
291     *
292     * @param uri the URI the file is for.
293     * @param mode the mode to read the file. MUST be "r" for readonly.
294     * @return the descriptor, or null if the file is not found or if mode is not equals to "r".
295     */
296    @Override
297    public AssetFileDescriptor openAssetFile(final Uri uri, final String mode) {
298        if (null == mode || !"r".equals(mode)) return null;
299
300        final int match = matchUri(uri);
301        if (DICTIONARY_V1_DICT_INFO != match && DICTIONARY_V2_DATAFILE != match) {
302            // Unsupported URI for openAssetFile
303            Log.w(TAG, "Unsupported URI for openAssetFile : " + uri);
304            return null;
305        }
306        final String wordlistId = uri.getLastPathSegment();
307        final String clientId = getClientId(uri);
308        final ContentValues wordList = getWordlistMetadataForWordlistId(clientId, wordlistId);
309
310        if (null == wordList) return null;
311
312        try {
313            final int status = wordList.getAsInteger(MetadataDbHelper.STATUS_COLUMN);
314            if (MetadataDbHelper.STATUS_DELETING == status) {
315                // This will return an empty file (R.raw.empty points at an empty dictionary)
316                // This is how we "delete" the files. It allows Android Keyboard to fake deleting
317                // a default dictionary - which is actually in its assets and can't be really
318                // deleted.
319                final AssetFileDescriptor afd = getContext().getResources().openRawResourceFd(
320                        R.raw.empty);
321                return afd;
322            } else {
323                final String localFilename =
324                        wordList.getAsString(MetadataDbHelper.LOCAL_FILENAME_COLUMN);
325                final File f = getContext().getFileStreamPath(localFilename);
326                final ParcelFileDescriptor pfd =
327                        ParcelFileDescriptor.open(f, ParcelFileDescriptor.MODE_READ_ONLY);
328                return new AssetFileDescriptor(pfd, 0, pfd.getStatSize());
329            }
330        } catch (FileNotFoundException e) {
331            // No file : fall through and return null
332        }
333        return null;
334    }
335
336    /**
337     * Reads the metadata and returns the collection of dictionaries for a given locale.
338     *
339     * Word list IDs are expected to be in the form category:manual_id. This method
340     * will select only one word list for each category: the one with the most specific
341     * locale matching the locale specified in the URI. The manual id serves only to
342     * distinguish a word list from another for the purpose of updating, and is arbitrary
343     * but may not contain a colon.
344     *
345     * @param clientId the ID of the client requesting the list
346     * @param locale the locale for which we want the list, as a String
347     * @param mayPrompt true if we are allowed to prompt the user for arbitration via notification
348     * @return a collection of ids. It is guaranteed to be non-null, but may be empty.
349     */
350    private Collection<WordListInfo> getDictionaryWordListsForLocale(final String clientId,
351            final String locale, final boolean mayPrompt) {
352        final Context context = getContext();
353        final Cursor results =
354                MetadataDbHelper.queryInstalledOrDeletingOrAvailableDictionaryMetadata(context,
355                        clientId);
356        if (null == results) {
357            return Collections.<WordListInfo>emptyList();
358        }
359        try {
360            final HashMap<String, WordListInfo> dicts = new HashMap<String, WordListInfo>();
361            final int idIndex = results.getColumnIndex(MetadataDbHelper.WORDLISTID_COLUMN);
362            final int localeIndex = results.getColumnIndex(MetadataDbHelper.LOCALE_COLUMN);
363            final int localFileNameIndex =
364                    results.getColumnIndex(MetadataDbHelper.LOCAL_FILENAME_COLUMN);
365            final int rawChecksumIndex =
366                    results.getColumnIndex(MetadataDbHelper.RAW_CHECKSUM_COLUMN);
367            final int statusIndex = results.getColumnIndex(MetadataDbHelper.STATUS_COLUMN);
368            if (results.moveToFirst()) {
369                do {
370                    final String wordListId = results.getString(idIndex);
371                    if (TextUtils.isEmpty(wordListId)) continue;
372                    final String[] wordListIdArray =
373                            TextUtils.split(wordListId, ID_CATEGORY_SEPARATOR);
374                    final String wordListCategory;
375                    if (2 == wordListIdArray.length) {
376                        // This is at the category:manual_id format.
377                        wordListCategory = wordListIdArray[0];
378                        // We don't need to read wordListIdArray[1] here, because it's irrelevant to
379                        // word list selection - it's just a name we use to identify which data file
380                        // is a newer version of which word list. We do however return the full id
381                        // string for each selected word list, so in this sense we are 'using' it.
382                    } else {
383                        // This does not contain a colon, like the old format does. Old-format IDs
384                        // always point to main dictionaries, so we force the main category upon it.
385                        wordListCategory = UpdateHandler.MAIN_DICTIONARY_CATEGORY;
386                    }
387                    final String wordListLocale = results.getString(localeIndex);
388                    final String wordListLocalFilename = results.getString(localFileNameIndex);
389                    final String wordListRawChecksum = results.getString(rawChecksumIndex);
390                    final int wordListStatus = results.getInt(statusIndex);
391                    // Test the requested locale against this wordlist locale. The requested locale
392                    // has to either match exactly or be more specific than the dictionary - a
393                    // dictionary for "en" would match both a request for "en" or for "en_US", but a
394                    // dictionary for "en_GB" would not match a request for "en_US". Thus if all
395                    // three of "en" "en_US" and "en_GB" dictionaries are installed, a request for
396                    // "en_US" would match "en" and "en_US", and a request for "en" only would only
397                    // match the generic "en" dictionary. For more details, see the documentation
398                    // for LocaleUtils#getMatchLevel.
399                    final int matchLevel = LocaleUtils.getMatchLevel(wordListLocale, locale);
400                    if (!LocaleUtils.isMatch(matchLevel)) {
401                        // The locale of this wordlist does not match the required locale.
402                        // Skip this wordlist and go to the next.
403                        continue;
404                    }
405                    if (MetadataDbHelper.STATUS_INSTALLED == wordListStatus) {
406                        // If the file does not exist, it has been deleted and the IME should
407                        // already have it. Do not return it. However, this only applies if the
408                        // word list is INSTALLED, for if it is DELETING we should return it always
409                        // so that Android Keyboard can perform the actual deletion.
410                        final File f = getContext().getFileStreamPath(wordListLocalFilename);
411                        if (!f.isFile()) {
412                            continue;
413                        }
414                    } else if (MetadataDbHelper.STATUS_AVAILABLE == wordListStatus) {
415                        // The locale is the id for the main dictionary.
416                        UpdateHandler.installIfNeverRequested(context, clientId, wordListId,
417                                mayPrompt);
418                        continue;
419                    }
420                    final WordListInfo currentBestMatch = dicts.get(wordListCategory);
421                    if (null == currentBestMatch
422                            || currentBestMatch.mMatchLevel < matchLevel) {
423                        dicts.put(wordListCategory, new WordListInfo(wordListId, wordListLocale,
424                                wordListRawChecksum, matchLevel));
425                    }
426                } while (results.moveToNext());
427            }
428            return Collections.unmodifiableCollection(dicts.values());
429        } finally {
430            results.close();
431        }
432    }
433
434    /**
435     * Deletes the file pointed by Uri, as returned by openAssetFile.
436     *
437     * @param uri the URI the file is for.
438     * @param selection ignored
439     * @param selectionArgs ignored
440     * @return the number of files deleted (0 or 1 in the current implementation)
441     * @see android.content.ContentProvider#delete(Uri, String, String[])
442     */
443    @Override
444    public int delete(final Uri uri, final String selection, final String[] selectionArgs)
445            throws UnsupportedOperationException {
446        final int match = matchUri(uri);
447        if (DICTIONARY_V1_DICT_INFO == match || DICTIONARY_V2_DATAFILE == match) {
448            return deleteDataFile(uri);
449        }
450        if (DICTIONARY_V2_METADATA == match) {
451            if (MetadataDbHelper.deleteClient(getContext(), getClientId(uri))) {
452                return 1;
453            }
454            return 0;
455        }
456        // Unsupported URI for delete
457        return 0;
458    }
459
460    private int deleteDataFile(final Uri uri) {
461        final String wordlistId = uri.getLastPathSegment();
462        final String clientId = getClientId(uri);
463        final ContentValues wordList = getWordlistMetadataForWordlistId(clientId, wordlistId);
464        if (null == wordList) return 0;
465        final int status = wordList.getAsInteger(MetadataDbHelper.STATUS_COLUMN);
466        final int version = wordList.getAsInteger(MetadataDbHelper.VERSION_COLUMN);
467        if (MetadataDbHelper.STATUS_DELETING == status) {
468            UpdateHandler.markAsDeleted(getContext(), clientId, wordlistId, version, status);
469            return 1;
470        } else if (MetadataDbHelper.STATUS_INSTALLED == status) {
471            final String result = uri.getQueryParameter(QUERY_PARAMETER_DELETE_RESULT);
472            if (QUERY_PARAMETER_FAILURE.equals(result)) {
473                UpdateHandler.markAsBroken(getContext(), clientId, wordlistId, version);
474            }
475            final String localFilename =
476                    wordList.getAsString(MetadataDbHelper.LOCAL_FILENAME_COLUMN);
477            final File f = getContext().getFileStreamPath(localFilename);
478            // f.delete() returns true if the file was successfully deleted, false otherwise
479            if (f.delete()) {
480                return 1;
481            } else {
482                return 0;
483            }
484        } else {
485            Log.e(TAG, "Attempt to delete a file whose status is " + status);
486            return 0;
487        }
488    }
489
490    /**
491     * Insert data into the provider. May be either a metadata source URL or some dictionary info.
492     *
493     * @param uri the designated content URI. See sUriMatcherV{1,2} for available URIs.
494     * @param values the values to insert for this content uri
495     * @return the URI for the newly inserted item. May be null if arguments don't allow for insert
496     */
497    @Override
498    public Uri insert(final Uri uri, final ContentValues values)
499            throws UnsupportedOperationException {
500        if (null == uri || null == values) return null; // Should never happen but let's be safe
501        PrivateLog.log("Insert, uri = " + uri.toString());
502        final String clientId = getClientId(uri);
503        switch (matchUri(uri)) {
504            case DICTIONARY_V2_METADATA:
505                // The values should contain a valid client ID and a valid URI for the metadata.
506                // The client ID may not be null, nor may it be empty because the empty client ID
507                // is reserved for internal use.
508                // The metadata URI may not be null, but it may be empty if the client does not
509                // want the dictionary pack to update the metadata automatically.
510                MetadataDbHelper.updateClientInfo(getContext(), clientId, values);
511                break;
512            case DICTIONARY_V2_DICT_INFO:
513                try {
514                    final WordListMetadata newDictionaryMetadata =
515                            WordListMetadata.createFromContentValues(
516                                    MetadataDbHelper.completeWithDefaultValues(values));
517                    new ActionBatch.MarkPreInstalledAction(clientId, newDictionaryMetadata)
518                            .execute(getContext());
519                } catch (final BadFormatException e) {
520                    Log.w(TAG, "Not enough information to insert this dictionary " + values, e);
521                }
522                // We just received new information about the list of dictionary for this client.
523                // For all intents and purposes, this is new metadata, so we should publish it
524                // so that any listeners (like the Settings interface for example) can update
525                // themselves.
526                UpdateHandler.publishUpdateMetadataCompleted(getContext(), true);
527                break;
528            case DICTIONARY_V1_WHOLE_LIST:
529            case DICTIONARY_V1_DICT_INFO:
530                PrivateLog.log("Attempt to insert : " + uri);
531                throw new UnsupportedOperationException(
532                        "Insertion in the dictionary is not supported in this version");
533        }
534        return uri;
535    }
536
537    /**
538     * Updating data is not supported, and will throw an exception.
539     * @see android.content.ContentProvider#update(Uri, ContentValues, String, String[])
540     * @see android.content.ContentProvider#insert(Uri, ContentValues)
541     */
542    @Override
543    public int update(final Uri uri, final ContentValues values, final String selection,
544            final String[] selectionArgs) throws UnsupportedOperationException {
545        PrivateLog.log("Attempt to update : " + uri);
546        throw new UnsupportedOperationException("Updating dictionary words is not supported");
547    }
548}
549