From e9a0e66716dab4dd3184d009d8920de1961efdfa Mon Sep 17 00:00:00 2001 From: Amin Bandali Date: Mon, 16 Dec 2024 21:45:41 -0500 Subject: Rename to Kelar Keyboard (org.kelar.inputmethod.latin) --- .../dictionarypack/DictionaryProvider.java | 541 +++++++++++++++++++++ 1 file changed, 541 insertions(+) create mode 100644 java/src/org/kelar/inputmethod/dictionarypack/DictionaryProvider.java (limited to 'java/src/org/kelar/inputmethod/dictionarypack/DictionaryProvider.java') diff --git a/java/src/org/kelar/inputmethod/dictionarypack/DictionaryProvider.java b/java/src/org/kelar/inputmethod/dictionarypack/DictionaryProvider.java new file mode 100644 index 000000000..fb3a4a391 --- /dev/null +++ b/java/src/org/kelar/inputmethod/dictionarypack/DictionaryProvider.java @@ -0,0 +1,541 @@ +/** + * 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 org.kelar.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 org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.common.LocaleUtils; +import org.kelar.inputmethod.latin.utils.DebugLogUtils; + +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_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 String mRawChecksum; + public final int mMatchLevel; + public WordListInfo(final String id, final String locale, final String rawChecksum, + final int matchLevel) { + mId = id; + mLocale = locale; + mRawChecksum = rawChecksum; + 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 = { MetadataDbHelper.WORDLISTID_COLUMN, + MetadataDbHelper.LOCALE_COLUMN, MetadataDbHelper.RAW_CHECKSUM_COLUMN }; + + // 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 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; + case 2: return mWordLists[mPos].mRawChecksum; + 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) { + DebugLogUtils.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); + DebugLogUtils.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(); + final Collection dictFiles = + getDictionaryWordListsForLocale(clientId, locale); + // 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); + } + PrivateLog.log("No dictionary files for this URL"); + return new ResourcePathCursor(Collections.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 Kelar 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; + } + 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 + * @return a collection of ids. It is guaranteed to be non-null, but may be empty. + */ + private Collection getDictionaryWordListsForLocale(final String clientId, + final String locale) { + final Context context = getContext(); + final Cursor results = + MetadataDbHelper.queryInstalledOrDeletingOrAvailableDictionaryMetadata(context, + clientId); + if (null == results) { + return Collections.emptyList(); + } + try { + final HashMap dicts = new HashMap<>(); + 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 rawChecksumIndex = + results.getColumnIndex(MetadataDbHelper.RAW_CHECKSUM_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 String wordListRawChecksum = results.getString(rawChecksumIndex); + 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 Kelar 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); + continue; + } + final WordListInfo currentBestMatch = dicts.get(wordListCategory); + if (null == currentBestMatch + || currentBestMatch.mMatchLevel < matchLevel) { + dicts.put(wordListCategory, new WordListInfo(wordListId, wordListLocale, + wordListRawChecksum, matchLevel)); + } + } while (results.moveToNext()); + } + return Collections.unmodifiableCollection(dicts.values()); + } finally { + results.close(); + } + } + + /** + * 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; + } + if (MetadataDbHelper.STATUS_INSTALLED == status) { + final String result = uri.getQueryParameter(QUERY_PARAMETER_DELETE_RESULT); + if (QUERY_PARAMETER_FAILURE.equals(result)) { + if (DEBUG) { + Log.d(TAG, + "Dictionary is broken, attempting to retry download & installation."); + } + UpdateHandler.markAsBrokenOrRetrying(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 + return f.delete() ? 1 : 0; + } + 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"); + } +} -- cgit v1.2.3-83-g751a