| /** |
| * Copyright (C) 2011 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); you may not |
| * use this file except in compliance with the License. You may obtain a copy of |
| * the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT |
| * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the |
| * License for the specific language governing permissions and limitations under |
| * the License. |
| */ |
| |
| package com.android.inputmethod.dictionarypack; |
| |
| import android.content.ContentProvider; |
| import android.content.ContentResolver; |
| import android.content.ContentValues; |
| import android.content.Context; |
| import android.content.UriMatcher; |
| import android.content.res.AssetFileDescriptor; |
| import android.database.AbstractCursor; |
| import android.database.Cursor; |
| import android.database.sqlite.SQLiteDatabase; |
| import android.net.Uri; |
| import android.os.ParcelFileDescriptor; |
| import android.text.TextUtils; |
| import android.util.Log; |
| |
| import com.android.inputmethod.latin.R; |
| |
| import java.io.File; |
| import java.io.FileNotFoundException; |
| import java.util.Collection; |
| import java.util.Collections; |
| import java.util.HashMap; |
| |
| /** |
| * Provider for dictionaries. |
| * |
| * This class is a ContentProvider exposing all available dictionary data as managed by |
| * the dictionary pack. |
| */ |
| public final class DictionaryProvider extends ContentProvider { |
| private static final String TAG = DictionaryProvider.class.getSimpleName(); |
| public static final boolean DEBUG = false; |
| |
| public static final Uri CONTENT_URI = |
| Uri.parse(ContentResolver.SCHEME_CONTENT + "://" + DictionaryPackConstants.AUTHORITY); |
| private static final String QUERY_PARAMETER_MAY_PROMPT_USER = "mayPrompt"; |
| private static final String QUERY_PARAMETER_TRUE = "true"; |
| private static final String QUERY_PARAMETER_DELETE_RESULT = "result"; |
| private static final String QUERY_PARAMETER_SUCCESS = "success"; |
| private static final String QUERY_PARAMETER_FAILURE = "failure"; |
| public static final String QUERY_PARAMETER_PROTOCOL_VERSION = "protocol"; |
| private static final int NO_MATCH = 0; |
| private static final int DICTIONARY_V1_WHOLE_LIST = 1; |
| private static final int DICTIONARY_V1_DICT_INFO = 2; |
| private static final int DICTIONARY_V2_METADATA = 3; |
| private static final int DICTIONARY_V2_WHOLE_LIST = 4; |
| private static final int DICTIONARY_V2_DICT_INFO = 5; |
| private static final int DICTIONARY_V2_DATAFILE = 6; |
| private static final UriMatcher sUriMatcherV1 = new UriMatcher(NO_MATCH); |
| private static final UriMatcher sUriMatcherV2 = new UriMatcher(NO_MATCH); |
| static |
| { |
| sUriMatcherV1.addURI(DictionaryPackConstants.AUTHORITY, "list", DICTIONARY_V1_WHOLE_LIST); |
| sUriMatcherV1.addURI(DictionaryPackConstants.AUTHORITY, "*", DICTIONARY_V1_DICT_INFO); |
| sUriMatcherV2.addURI(DictionaryPackConstants.AUTHORITY, "*/metadata", |
| DICTIONARY_V2_METADATA); |
| sUriMatcherV2.addURI(DictionaryPackConstants.AUTHORITY, "*/list", DICTIONARY_V2_WHOLE_LIST); |
| sUriMatcherV2.addURI(DictionaryPackConstants.AUTHORITY, "*/dict/*", |
| DICTIONARY_V2_DICT_INFO); |
| sUriMatcherV2.addURI(DictionaryPackConstants.AUTHORITY, "*/datafile/*", |
| DICTIONARY_V2_DATAFILE); |
| } |
| |
| // MIME types for dictionary and dictionary list, as required by ContentProvider contract. |
| public static final String DICT_LIST_MIME_TYPE = |
| "vnd.android.cursor.item/vnd.google.dictionarylist"; |
| public static final String DICT_DATAFILE_MIME_TYPE = |
| "vnd.android.cursor.item/vnd.google.dictionary"; |
| |
| public static final String ID_CATEGORY_SEPARATOR = ":"; |
| |
| private static final class WordListInfo { |
| public final String mId; |
| public final String mLocale; |
| public final int mMatchLevel; |
| public WordListInfo(final String id, final String locale, final int matchLevel) { |
| mId = id; |
| mLocale = locale; |
| mMatchLevel = matchLevel; |
| } |
| } |
| |
| /** |
| * A cursor for returning a list of file ids from a List of strings. |
| * |
| * This simulates only the necessary methods. It has no error handling to speak of, |
| * and does not support everything a database does, only a few select necessary methods. |
| */ |
| private static final class ResourcePathCursor extends AbstractCursor { |
| |
| // Column names for the cursor returned by this content provider. |
| static private final String[] columnNames = { "id", "locale" }; |
| |
| // The list of word lists served by this provider that match the client request. |
| final WordListInfo[] mWordLists; |
| // Note : the cursor also uses mPos, which is defined in AbstractCursor. |
| |
| public ResourcePathCursor(final Collection<WordListInfo> wordLists) { |
| // Allocating a 0-size WordListInfo here allows the toArray() method |
| // to ensure we have a strongly-typed array. It's thrown out. That's |
| // what the documentation of #toArray says to do in order to get a |
| // new strongly typed array of the correct size. |
| mWordLists = wordLists.toArray(new WordListInfo[0]); |
| mPos = 0; |
| } |
| |
| @Override |
| public String[] getColumnNames() { |
| return columnNames; |
| } |
| |
| @Override |
| public int getCount() { |
| return mWordLists.length; |
| } |
| |
| @Override public double getDouble(int column) { return 0; } |
| @Override public float getFloat(int column) { return 0; } |
| @Override public int getInt(int column) { return 0; } |
| @Override public short getShort(int column) { return 0; } |
| @Override public long getLong(int column) { return 0; } |
| |
| @Override public String getString(final int column) { |
| switch (column) { |
| case 0: return mWordLists[mPos].mId; |
| case 1: return mWordLists[mPos].mLocale; |
| default : return null; |
| } |
| } |
| |
| @Override |
| public boolean isNull(final int column) { |
| if (mPos >= mWordLists.length) return true; |
| return column != 0; |
| } |
| } |
| |
| @Override |
| public boolean onCreate() { |
| return true; |
| } |
| |
| private static int matchUri(final Uri uri) { |
| int protocolVersion = 1; |
| final String protocolVersionArg = uri.getQueryParameter(QUERY_PARAMETER_PROTOCOL_VERSION); |
| if ("2".equals(protocolVersionArg)) protocolVersion = 2; |
| switch (protocolVersion) { |
| case 1: return sUriMatcherV1.match(uri); |
| case 2: return sUriMatcherV2.match(uri); |
| default: return NO_MATCH; |
| } |
| } |
| |
| private static String getClientId(final Uri uri) { |
| int protocolVersion = 1; |
| final String protocolVersionArg = uri.getQueryParameter(QUERY_PARAMETER_PROTOCOL_VERSION); |
| if ("2".equals(protocolVersionArg)) protocolVersion = 2; |
| switch (protocolVersion) { |
| case 1: return null; // In protocol 1, the client ID is always null. |
| case 2: return uri.getPathSegments().get(0); |
| default: return null; |
| } |
| } |
| |
| /** |
| * Returns the MIME type of the content associated with an Uri |
| * |
| * @see android.content.ContentProvider#getType(android.net.Uri) |
| * |
| * @param uri the URI of the content the type of which should be returned. |
| * @return the MIME type, or null if the URL is not recognized. |
| */ |
| @Override |
| public String getType(final Uri uri) { |
| PrivateLog.log("Asked for type of : " + uri); |
| final int match = matchUri(uri); |
| switch (match) { |
| case NO_MATCH: return null; |
| case DICTIONARY_V1_WHOLE_LIST: |
| case DICTIONARY_V1_DICT_INFO: |
| case DICTIONARY_V2_WHOLE_LIST: |
| case DICTIONARY_V2_DICT_INFO: return DICT_LIST_MIME_TYPE; |
| case DICTIONARY_V2_DATAFILE: return DICT_DATAFILE_MIME_TYPE; |
| default: return null; |
| } |
| } |
| |
| /** |
| * Query the provider for dictionary files. |
| * |
| * This version dispatches the query according to the protocol version found in the |
| * ?protocol= query parameter. If absent or not well-formed, it defaults to 1. |
| * @see android.content.ContentProvider#query(Uri, String[], String, String[], String) |
| * |
| * @param uri a content uri (see sUriMatcherV{1,2} at the top of this file for format) |
| * @param projection ignored. All columns are always returned. |
| * @param selection ignored. |
| * @param selectionArgs ignored. |
| * @param sortOrder ignored. The results are always returned in no particular order. |
| * @return a cursor matching the uri, or null if the URI was not recognized. |
| */ |
| @Override |
| public Cursor query(final Uri uri, final String[] projection, final String selection, |
| final String[] selectionArgs, final String sortOrder) { |
| Utils.l("Uri =", uri); |
| PrivateLog.log("Query : " + uri); |
| final String clientId = getClientId(uri); |
| final int match = matchUri(uri); |
| switch (match) { |
| case DICTIONARY_V1_WHOLE_LIST: |
| case DICTIONARY_V2_WHOLE_LIST: |
| final Cursor c = MetadataDbHelper.queryDictionaries(getContext(), clientId); |
| Utils.l("List of dictionaries with count", c.getCount()); |
| PrivateLog.log("Returned a list of " + c.getCount() + " items"); |
| return c; |
| case DICTIONARY_V2_DICT_INFO: |
| // In protocol version 2, we return null if the client is unknown. Otherwise |
| // we behave exactly like for protocol 1. |
| if (!MetadataDbHelper.isClientKnown(getContext(), clientId)) return null; |
| // Fall through |
| case DICTIONARY_V1_DICT_INFO: |
| final String locale = uri.getLastPathSegment(); |
| // If LatinIME does not have a dictionary for this locale at all, it will |
| // send us true for this value. In this case, we may prompt the user for |
| // a decision about downloading a dictionary even over a metered connection. |
| final String mayPromptValue = |
| uri.getQueryParameter(QUERY_PARAMETER_MAY_PROMPT_USER); |
| final boolean mayPrompt = QUERY_PARAMETER_TRUE.equals(mayPromptValue); |
| final Collection<WordListInfo> dictFiles = |
| getDictionaryWordListsForLocale(clientId, locale, mayPrompt); |
| // TODO: pass clientId to the following function |
| DictionaryService.updateNowIfNotUpdatedInAVeryLongTime(getContext()); |
| if (null != dictFiles && dictFiles.size() > 0) { |
| PrivateLog.log("Returned " + dictFiles.size() + " files"); |
| return new ResourcePathCursor(dictFiles); |
| } else { |
| PrivateLog.log("No dictionary files for this URL"); |
| return new ResourcePathCursor(Collections.<WordListInfo>emptyList()); |
| } |
| // V2_METADATA and V2_DATAFILE are not supported for query() |
| default: |
| return null; |
| } |
| } |
| |
| /** |
| * Helper method to get the wordlist metadata associated with a wordlist ID. |
| * |
| * @param clientId the ID of the client |
| * @param wordlistId the ID of the wordlist for which to get the metadata. |
| * @return the metadata for this wordlist ID, or null if none could be found. |
| */ |
| private ContentValues getWordlistMetadataForWordlistId(final String clientId, |
| final String wordlistId) { |
| final Context context = getContext(); |
| if (TextUtils.isEmpty(wordlistId)) return null; |
| final SQLiteDatabase db = MetadataDbHelper.getDb(context, clientId); |
| return MetadataDbHelper.getInstalledOrDeletingWordListContentValuesByWordListId( |
| db, wordlistId); |
| } |
| |
| /** |
| * Opens an asset file for an URI. |
| * |
| * Called by {@link android.content.ContentResolver#openAssetFileDescriptor(Uri, String)} or |
| * {@link android.content.ContentResolver#openInputStream(Uri)} from a client requesting a |
| * dictionary. |
| * @see android.content.ContentProvider#openAssetFile(Uri, String) |
| * |
| * @param uri the URI the file is for. |
| * @param mode the mode to read the file. MUST be "r" for readonly. |
| * @return the descriptor, or null if the file is not found or if mode is not equals to "r". |
| */ |
| @Override |
| public AssetFileDescriptor openAssetFile(final Uri uri, final String mode) { |
| if (null == mode || !"r".equals(mode)) return null; |
| |
| final int match = matchUri(uri); |
| if (DICTIONARY_V1_DICT_INFO != match && DICTIONARY_V2_DATAFILE != match) { |
| // Unsupported URI for openAssetFile |
| Log.w(TAG, "Unsupported URI for openAssetFile : " + uri); |
| return null; |
| } |
| final String wordlistId = uri.getLastPathSegment(); |
| final String clientId = getClientId(uri); |
| final ContentValues wordList = getWordlistMetadataForWordlistId(clientId, wordlistId); |
| |
| if (null == wordList) return null; |
| |
| try { |
| final int status = wordList.getAsInteger(MetadataDbHelper.STATUS_COLUMN); |
| if (MetadataDbHelper.STATUS_DELETING == status) { |
| // This will return an empty file (R.raw.empty points at an empty dictionary) |
| // This is how we "delete" the files. It allows Android Keyboard to fake deleting |
| // a default dictionary - which is actually in its assets and can't be really |
| // deleted. |
| final AssetFileDescriptor afd = getContext().getResources().openRawResourceFd( |
| R.raw.empty); |
| return afd; |
| } else { |
| final String localFilename = |
| wordList.getAsString(MetadataDbHelper.LOCAL_FILENAME_COLUMN); |
| final File f = getContext().getFileStreamPath(localFilename); |
| final ParcelFileDescriptor pfd = |
| ParcelFileDescriptor.open(f, ParcelFileDescriptor.MODE_READ_ONLY); |
| return new AssetFileDescriptor(pfd, 0, pfd.getStatSize()); |
| } |
| } catch (FileNotFoundException e) { |
| // No file : fall through and return null |
| } |
| return null; |
| } |
| |
| /** |
| * Reads the metadata and returns the collection of dictionaries for a given locale. |
| * |
| * Word list IDs are expected to be in the form category:manual_id. This method |
| * will select only one word list for each category: the one with the most specific |
| * locale matching the locale specified in the URI. The manual id serves only to |
| * distinguish a word list from another for the purpose of updating, and is arbitrary |
| * but may not contain a colon. |
| * |
| * @param clientId the ID of the client requesting the list |
| * @param locale the locale for which we want the list, as a String |
| * @param mayPrompt true if we are allowed to prompt the user for arbitration via notification |
| * @return a collection of ids. It is guaranteed to be non-null, but may be empty. |
| */ |
| private Collection<WordListInfo> getDictionaryWordListsForLocale(final String clientId, |
| final String locale, final boolean mayPrompt) { |
| final Context context = getContext(); |
| final Cursor results = |
| MetadataDbHelper.queryInstalledOrDeletingOrAvailableDictionaryMetadata(context, |
| clientId); |
| if (null == results) { |
| return Collections.<WordListInfo>emptyList(); |
| } else { |
| final HashMap<String, WordListInfo> dicts = new HashMap<String, WordListInfo>(); |
| final int idIndex = results.getColumnIndex(MetadataDbHelper.WORDLISTID_COLUMN); |
| final int localeIndex = results.getColumnIndex(MetadataDbHelper.LOCALE_COLUMN); |
| final int localFileNameIndex = |
| results.getColumnIndex(MetadataDbHelper.LOCAL_FILENAME_COLUMN); |
| final int statusIndex = results.getColumnIndex(MetadataDbHelper.STATUS_COLUMN); |
| if (results.moveToFirst()) { |
| do { |
| final String wordListId = results.getString(idIndex); |
| if (TextUtils.isEmpty(wordListId)) continue; |
| final String[] wordListIdArray = |
| TextUtils.split(wordListId, ID_CATEGORY_SEPARATOR); |
| final String wordListCategory; |
| if (2 == wordListIdArray.length) { |
| // This is at the category:manual_id format. |
| wordListCategory = wordListIdArray[0]; |
| // We don't need to read wordListIdArray[1] here, because it's irrelevant to |
| // word list selection - it's just a name we use to identify which data file |
| // is a newer version of which word list. We do however return the full id |
| // string for each selected word list, so in this sense we are 'using' it. |
| } else { |
| // This does not contain a colon, like the old format does. Old-format IDs |
| // always point to main dictionaries, so we force the main category upon it. |
| wordListCategory = UpdateHandler.MAIN_DICTIONARY_CATEGORY; |
| } |
| final String wordListLocale = results.getString(localeIndex); |
| final String wordListLocalFilename = results.getString(localFileNameIndex); |
| final int wordListStatus = results.getInt(statusIndex); |
| // Test the requested locale against this wordlist locale. The requested locale |
| // has to either match exactly or be more specific than the dictionary - a |
| // dictionary for "en" would match both a request for "en" or for "en_US", but a |
| // dictionary for "en_GB" would not match a request for "en_US". Thus if all |
| // three of "en" "en_US" and "en_GB" dictionaries are installed, a request for |
| // "en_US" would match "en" and "en_US", and a request for "en" only would only |
| // match the generic "en" dictionary. For more details, see the documentation |
| // for LocaleUtils#getMatchLevel. |
| final int matchLevel = LocaleUtils.getMatchLevel(wordListLocale, locale); |
| if (!LocaleUtils.isMatch(matchLevel)) { |
| // The locale of this wordlist does not match the required locale. |
| // Skip this wordlist and go to the next. |
| continue; |
| } |
| if (MetadataDbHelper.STATUS_INSTALLED == wordListStatus) { |
| // If the file does not exist, it has been deleted and the IME should |
| // already have it. Do not return it. However, this only applies if the |
| // word list is INSTALLED, for if it is DELETING we should return it always |
| // so that Android Keyboard can perform the actual deletion. |
| final File f = getContext().getFileStreamPath(wordListLocalFilename); |
| if (!f.isFile()) { |
| continue; |
| } |
| } else if (MetadataDbHelper.STATUS_AVAILABLE == wordListStatus) { |
| // The locale is the id for the main dictionary. |
| UpdateHandler.installIfNeverRequested(context, clientId, wordListId, |
| mayPrompt); |
| continue; |
| } |
| final WordListInfo currentBestMatch = dicts.get(wordListCategory); |
| if (null == currentBestMatch |
| || currentBestMatch.mMatchLevel < matchLevel) { |
| dicts.put(wordListCategory, |
| new WordListInfo(wordListId, wordListLocale, matchLevel)); |
| } |
| } while (results.moveToNext()); |
| } |
| results.close(); |
| return Collections.unmodifiableCollection(dicts.values()); |
| } |
| } |
| |
| /** |
| * Deletes the file pointed by Uri, as returned by openAssetFile. |
| * |
| * @param uri the URI the file is for. |
| * @param selection ignored |
| * @param selectionArgs ignored |
| * @return the number of files deleted (0 or 1 in the current implementation) |
| * @see android.content.ContentProvider#delete(Uri, String, String[]) |
| */ |
| @Override |
| public int delete(final Uri uri, final String selection, final String[] selectionArgs) |
| throws UnsupportedOperationException { |
| final int match = matchUri(uri); |
| if (DICTIONARY_V1_DICT_INFO == match || DICTIONARY_V2_DATAFILE == match) { |
| return deleteDataFile(uri); |
| } |
| if (DICTIONARY_V2_METADATA == match) { |
| if (MetadataDbHelper.deleteClient(getContext(), getClientId(uri))) { |
| return 1; |
| } |
| return 0; |
| } |
| // Unsupported URI for delete |
| return 0; |
| } |
| |
| private int deleteDataFile(final Uri uri) { |
| final String wordlistId = uri.getLastPathSegment(); |
| final String clientId = getClientId(uri); |
| final ContentValues wordList = getWordlistMetadataForWordlistId(clientId, wordlistId); |
| if (null == wordList) return 0; |
| final int status = wordList.getAsInteger(MetadataDbHelper.STATUS_COLUMN); |
| final int version = wordList.getAsInteger(MetadataDbHelper.VERSION_COLUMN); |
| if (MetadataDbHelper.STATUS_DELETING == status) { |
| UpdateHandler.markAsDeleted(getContext(), clientId, wordlistId, version, status); |
| return 1; |
| } else if (MetadataDbHelper.STATUS_INSTALLED == status) { |
| final String result = uri.getQueryParameter(QUERY_PARAMETER_DELETE_RESULT); |
| if (QUERY_PARAMETER_FAILURE.equals(result)) { |
| UpdateHandler.markAsBroken(getContext(), clientId, wordlistId, version); |
| } |
| final String localFilename = |
| wordList.getAsString(MetadataDbHelper.LOCAL_FILENAME_COLUMN); |
| final File f = getContext().getFileStreamPath(localFilename); |
| // f.delete() returns true if the file was successfully deleted, false otherwise |
| if (f.delete()) { |
| return 1; |
| } else { |
| return 0; |
| } |
| } else { |
| Log.e(TAG, "Attempt to delete a file whose status is " + status); |
| return 0; |
| } |
| } |
| |
| /** |
| * Insert data into the provider. May be either a metadata source URL or some dictionary info. |
| * |
| * @param uri the designated content URI. See sUriMatcherV{1,2} for available URIs. |
| * @param values the values to insert for this content uri |
| * @return the URI for the newly inserted item. May be null if arguments don't allow for insert |
| */ |
| @Override |
| public Uri insert(final Uri uri, final ContentValues values) |
| throws UnsupportedOperationException { |
| if (null == uri || null == values) return null; // Should never happen but let's be safe |
| PrivateLog.log("Insert, uri = " + uri.toString()); |
| final String clientId = getClientId(uri); |
| switch (matchUri(uri)) { |
| case DICTIONARY_V2_METADATA: |
| // The values should contain a valid client ID and a valid URI for the metadata. |
| // The client ID may not be null, nor may it be empty because the empty client ID |
| // is reserved for internal use. |
| // The metadata URI may not be null, but it may be empty if the client does not |
| // want the dictionary pack to update the metadata automatically. |
| MetadataDbHelper.updateClientInfo(getContext(), clientId, values); |
| break; |
| case DICTIONARY_V2_DICT_INFO: |
| try { |
| final WordListMetadata newDictionaryMetadata = |
| WordListMetadata.createFromContentValues( |
| MetadataDbHelper.completeWithDefaultValues(values)); |
| new ActionBatch.MarkPreInstalledAction(clientId, newDictionaryMetadata) |
| .execute(getContext()); |
| } catch (final BadFormatException e) { |
| Log.w(TAG, "Not enough information to insert this dictionary " + values, e); |
| } |
| // We just received new information about the list of dictionary for this client. |
| // For all intents and purposes, this is new metadata, so we should publish it |
| // so that any listeners (like the Settings interface for example) can update |
| // themselves. |
| UpdateHandler.publishUpdateMetadataCompleted(getContext(), true); |
| break; |
| case DICTIONARY_V1_WHOLE_LIST: |
| case DICTIONARY_V1_DICT_INFO: |
| PrivateLog.log("Attempt to insert : " + uri); |
| throw new UnsupportedOperationException( |
| "Insertion in the dictionary is not supported in this version"); |
| } |
| return uri; |
| } |
| |
| /** |
| * Updating data is not supported, and will throw an exception. |
| * @see android.content.ContentProvider#update(Uri, ContentValues, String, String[]) |
| * @see android.content.ContentProvider#insert(Uri, ContentValues) |
| */ |
| @Override |
| public int update(final Uri uri, final ContentValues values, final String selection, |
| final String[] selectionArgs) throws UnsupportedOperationException { |
| PrivateLog.log("Attempt to update : " + uri); |
| throw new UnsupportedOperationException("Updating dictionary words is not supported"); |
| } |
| } |