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