diff options
Diffstat (limited to 'java/src/org/kelar/inputmethod/dictionarypack')
29 files changed, 6125 insertions, 0 deletions
diff --git a/java/src/org/kelar/inputmethod/dictionarypack/ActionBatch.java b/java/src/org/kelar/inputmethod/dictionarypack/ActionBatch.java new file mode 100644 index 000000000..06bebc8da --- /dev/null +++ b/java/src/org/kelar/inputmethod/dictionarypack/ActionBatch.java @@ -0,0 +1,625 @@ +/* + * 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.app.DownloadManager.Request; +import android.content.ContentValues; +import android.content.Context; +import android.content.res.Resources; +import android.database.sqlite.SQLiteDatabase; +import android.net.Uri; +import android.text.TextUtils; +import android.util.Log; + +import org.kelar.inputmethod.latin.BinaryDictionaryFileDumper; +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.common.LocaleUtils; +import org.kelar.inputmethod.latin.utils.ApplicationUtils; +import org.kelar.inputmethod.latin.utils.DebugLogUtils; + +import java.util.LinkedList; +import java.util.Queue; + +/** + * Object representing an upgrade from one state to another. + * + * This implementation basically encapsulates a list of Runnable objects. In the future + * it may manage dependencies between them. Concretely, it does not use Runnable because the + * actions need an argument. + */ +/* + +The state of a word list follows the following scheme. + + | ^ + MakeAvailable | + | .------------Forget--------' + V | + STATUS_AVAILABLE <-------------------------. + | | +StartDownloadAction FinishDeleteAction + | | + V | +STATUS_DOWNLOADING EnableAction-- STATUS_DELETING + | | ^ +InstallAfterDownloadAction | | + | .---------------' StartDeleteAction + | | | + V V | + STATUS_INSTALLED <--EnableAction-- STATUS_DISABLED + --DisableAction--> + + It may also be possible that DisableAction or StartDeleteAction or + DownloadAction run when the file is still downloading. This cancels + the download and returns to STATUS_AVAILABLE. + Also, an UpdateDataAction may apply in any state. It does not affect + the state in any way (nor type, local filename, id or version) but + may update other attributes like description or remote filename. + + Forget is an DB maintenance action that removes the entry if it is not installed or disabled. + This happens when the word list information disappeared from the server, or when a new version + is available and we should forget about the old one. +*/ +public final class ActionBatch { + /** + * A piece of update. + * + * Action is basically like a Runnable that takes an argument. + */ + public interface Action { + /** + * Execute this action NOW. + * @param context the context to get system services, resources, databases + */ + void execute(final Context context); + } + + /** + * An action that starts downloading an available word list. + */ + public static final class StartDownloadAction implements Action { + static final String TAG = "DictionaryProvider:" + StartDownloadAction.class.getSimpleName(); + + private final String mClientId; + // The data to download. May not be null. + final WordListMetadata mWordList; + public StartDownloadAction(final String clientId, final WordListMetadata wordList) { + DebugLogUtils.l("New download action for client ", clientId, " : ", wordList); + mClientId = clientId; + mWordList = wordList; + } + + @Override + public void execute(final Context context) { + if (null == mWordList) { // This should never happen + Log.e(TAG, "UpdateAction with a null parameter!"); + return; + } + DebugLogUtils.l("Downloading word list"); + final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId); + final ContentValues values = MetadataDbHelper.getContentValuesByWordListId(db, + mWordList.mId, mWordList.mVersion); + final int status = values.getAsInteger(MetadataDbHelper.STATUS_COLUMN); + final DownloadManagerWrapper manager = new DownloadManagerWrapper(context); + if (MetadataDbHelper.STATUS_DOWNLOADING == status) { + // The word list is still downloading. Cancel the download and revert the + // word list status to "available". + manager.remove(values.getAsLong(MetadataDbHelper.PENDINGID_COLUMN)); + MetadataDbHelper.markEntryAsAvailable(db, mWordList.mId, mWordList.mVersion); + } else if (MetadataDbHelper.STATUS_AVAILABLE != status + && MetadataDbHelper.STATUS_RETRYING != status) { + // Should never happen + Log.e(TAG, "Unexpected state of the word list '" + mWordList.mId + "' : " + status + + " for an upgrade action. Fall back to download."); + } + // Download it. + DebugLogUtils.l("Upgrade word list, downloading", mWordList.mRemoteFilename); + + // This is an upgraded word list: we should download it. + // Adding a disambiguator to circumvent a bug in older versions of DownloadManager. + // DownloadManager also stupidly cuts the extension to replace with its own that it + // gets from the content-type. We need to circumvent this. + final String disambiguator = "#" + System.currentTimeMillis() + + ApplicationUtils.getVersionName(context) + ".dict"; + final Uri uri = Uri.parse(mWordList.mRemoteFilename + disambiguator); + final Request request = new Request(uri); + + final Resources res = context.getResources(); + request.setAllowedNetworkTypes(Request.NETWORK_WIFI | Request.NETWORK_MOBILE); + request.setTitle(mWordList.mDescription); + request.setNotificationVisibility(Request.VISIBILITY_HIDDEN); + request.setVisibleInDownloadsUi( + res.getBoolean(R.bool.dict_downloads_visible_in_download_UI)); + + final long downloadId = UpdateHandler.registerDownloadRequest(manager, request, db, + mWordList.mId, mWordList.mVersion); + Log.i(TAG, String.format("Starting the dictionary download with version:" + + " %d and Url: %s", mWordList.mVersion, uri)); + DebugLogUtils.l("Starting download of", uri, "with id", downloadId); + PrivateLog.log("Starting download of " + uri + ", id : " + downloadId); + } + } + + /** + * An action that updates the database to reflect the status of a newly installed word list. + */ + public static final class InstallAfterDownloadAction implements Action { + static final String TAG = "DictionaryProvider:" + + InstallAfterDownloadAction.class.getSimpleName(); + private final String mClientId; + // The state to upgrade from. May not be null. + final ContentValues mWordListValues; + + public InstallAfterDownloadAction(final String clientId, + final ContentValues wordListValues) { + DebugLogUtils.l("New InstallAfterDownloadAction for client ", clientId, " : ", + wordListValues); + mClientId = clientId; + mWordListValues = wordListValues; + } + + @Override + public void execute(final Context context) { + if (null == mWordListValues) { + Log.e(TAG, "InstallAfterDownloadAction with a null parameter!"); + return; + } + final int status = mWordListValues.getAsInteger(MetadataDbHelper.STATUS_COLUMN); + if (MetadataDbHelper.STATUS_DOWNLOADING != status) { + final String id = mWordListValues.getAsString(MetadataDbHelper.WORDLISTID_COLUMN); + Log.e(TAG, "Unexpected state of the word list '" + id + "' : " + status + + " for an InstallAfterDownload action. Bailing out."); + return; + } + + DebugLogUtils.l("Setting word list as installed"); + final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId); + MetadataDbHelper.markEntryAsFinishedDownloadingAndInstalled(db, mWordListValues); + + // Install the downloaded file by un-compressing and moving it to the staging + // directory. Ideally, we should do this before updating the DB, but the + // installDictToStagingFromContentProvider() relies on the db being updated. + final String localeString = mWordListValues.getAsString(MetadataDbHelper.LOCALE_COLUMN); + BinaryDictionaryFileDumper.installDictToStagingFromContentProvider( + LocaleUtils.constructLocaleFromString(localeString), context, false); + } + } + + /** + * An action that enables an existing word list. + */ + public static final class EnableAction implements Action { + static final String TAG = "DictionaryProvider:" + EnableAction.class.getSimpleName(); + private final String mClientId; + // The state to upgrade from. May not be null. + final WordListMetadata mWordList; + + public EnableAction(final String clientId, final WordListMetadata wordList) { + DebugLogUtils.l("New EnableAction for client ", clientId, " : ", wordList); + mClientId = clientId; + mWordList = wordList; + } + + @Override + public void execute(final Context context) { + if (null == mWordList) { + Log.e(TAG, "EnableAction with a null parameter!"); + return; + } + DebugLogUtils.l("Enabling word list"); + final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId); + final ContentValues values = MetadataDbHelper.getContentValuesByWordListId(db, + mWordList.mId, mWordList.mVersion); + final int status = values.getAsInteger(MetadataDbHelper.STATUS_COLUMN); + if (MetadataDbHelper.STATUS_DISABLED != status + && MetadataDbHelper.STATUS_DELETING != status) { + Log.e(TAG, "Unexpected state of the word list '" + mWordList.mId + " : " + status + + " for an enable action. Cancelling"); + return; + } + MetadataDbHelper.markEntryAsEnabled(db, mWordList.mId, mWordList.mVersion); + } + } + + /** + * An action that disables a word list. + */ + public static final class DisableAction implements Action { + static final String TAG = "DictionaryProvider:" + DisableAction.class.getSimpleName(); + private final String mClientId; + // The word list to disable. May not be null. + final WordListMetadata mWordList; + public DisableAction(final String clientId, final WordListMetadata wordlist) { + DebugLogUtils.l("New Disable action for client ", clientId, " : ", wordlist); + mClientId = clientId; + mWordList = wordlist; + } + + @Override + public void execute(final Context context) { + if (null == mWordList) { // This should never happen + Log.e(TAG, "DisableAction with a null word list!"); + return; + } + DebugLogUtils.l("Disabling word list : " + mWordList); + final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId); + final ContentValues values = MetadataDbHelper.getContentValuesByWordListId(db, + mWordList.mId, mWordList.mVersion); + final int status = values.getAsInteger(MetadataDbHelper.STATUS_COLUMN); + if (MetadataDbHelper.STATUS_INSTALLED == status) { + // Disabling an installed word list + MetadataDbHelper.markEntryAsDisabled(db, mWordList.mId, mWordList.mVersion); + } else { + if (MetadataDbHelper.STATUS_DOWNLOADING != status) { + Log.e(TAG, "Unexpected state of the word list '" + mWordList.mId + "' : " + + status + " for a disable action. Fall back to marking as available."); + } + // The word list is still downloading. Cancel the download and revert the + // word list status to "available". + final DownloadManagerWrapper manager = new DownloadManagerWrapper(context); + manager.remove(values.getAsLong(MetadataDbHelper.PENDINGID_COLUMN)); + MetadataDbHelper.markEntryAsAvailable(db, mWordList.mId, mWordList.mVersion); + } + } + } + + /** + * An action that makes a word list available. + */ + public static final class MakeAvailableAction implements Action { + static final String TAG = "DictionaryProvider:" + MakeAvailableAction.class.getSimpleName(); + private final String mClientId; + // The word list to make available. May not be null. + final WordListMetadata mWordList; + public MakeAvailableAction(final String clientId, final WordListMetadata wordlist) { + DebugLogUtils.l("New MakeAvailable action", clientId, " : ", wordlist); + mClientId = clientId; + mWordList = wordlist; + } + + @Override + public void execute(final Context context) { + if (null == mWordList) { // This should never happen + Log.e(TAG, "MakeAvailableAction with a null word list!"); + return; + } + final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId); + if (null != MetadataDbHelper.getContentValuesByWordListId(db, + mWordList.mId, mWordList.mVersion)) { + Log.e(TAG, "Unexpected state of the word list '" + mWordList.mId + "' " + + " for a makeavailable action. Marking as available anyway."); + } + DebugLogUtils.l("Making word list available : " + mWordList); + // If mLocalFilename is null, then it's a remote file that hasn't been downloaded + // yet, so we set the local filename to the empty string. + final ContentValues values = MetadataDbHelper.makeContentValues(0, + MetadataDbHelper.TYPE_BULK, MetadataDbHelper.STATUS_AVAILABLE, + mWordList.mId, mWordList.mLocale, mWordList.mDescription, + null == mWordList.mLocalFilename ? "" : mWordList.mLocalFilename, + mWordList.mRemoteFilename, mWordList.mLastUpdate, mWordList.mRawChecksum, + mWordList.mChecksum, mWordList.mRetryCount, mWordList.mFileSize, + mWordList.mVersion, mWordList.mFormatVersion); + PrivateLog.log("Insert 'available' record for " + mWordList.mDescription + + " and locale " + mWordList.mLocale); + db.insert(MetadataDbHelper.METADATA_TABLE_NAME, null, values); + } + } + + /** + * An action that marks a word list as pre-installed. + * + * This is almost the same as MakeAvailableAction, as it only inserts a line with parameters + * received from outside. + * Unlike MakeAvailableAction, the parameters are not received from a downloaded metadata file + * but from the client directly; it marks a word list as being "installed" and not "available". + * It also explicitly sets the filename to the empty string, so that we don't try to open + * it on our side. + */ + public static final class MarkPreInstalledAction implements Action { + static final String TAG = "DictionaryProvider:" + + MarkPreInstalledAction.class.getSimpleName(); + private final String mClientId; + // The word list to mark pre-installed. May not be null. + final WordListMetadata mWordList; + public MarkPreInstalledAction(final String clientId, final WordListMetadata wordlist) { + DebugLogUtils.l("New MarkPreInstalled action", clientId, " : ", wordlist); + mClientId = clientId; + mWordList = wordlist; + } + + @Override + public void execute(final Context context) { + if (null == mWordList) { // This should never happen + Log.e(TAG, "MarkPreInstalledAction with a null word list!"); + return; + } + final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId); + if (null != MetadataDbHelper.getContentValuesByWordListId(db, + mWordList.mId, mWordList.mVersion)) { + Log.e(TAG, "Unexpected state of the word list '" + mWordList.mId + "' " + + " for a markpreinstalled action. Marking as preinstalled anyway."); + } + DebugLogUtils.l("Marking word list preinstalled : " + mWordList); + // This word list is pre-installed : we don't have its file. We should reset + // the local file name to the empty string so that we don't try to open it + // accidentally. The remote filename may be set by the application if it so wishes. + final ContentValues values = MetadataDbHelper.makeContentValues(0, + MetadataDbHelper.TYPE_BULK, MetadataDbHelper.STATUS_INSTALLED, + mWordList.mId, mWordList.mLocale, mWordList.mDescription, + TextUtils.isEmpty(mWordList.mLocalFilename) ? "" : mWordList.mLocalFilename, + mWordList.mRemoteFilename, mWordList.mLastUpdate, + mWordList.mRawChecksum, mWordList.mChecksum, mWordList.mRetryCount, + mWordList.mFileSize, mWordList.mVersion, mWordList.mFormatVersion); + PrivateLog.log("Insert 'preinstalled' record for " + mWordList.mDescription + + " and locale " + mWordList.mLocale); + db.insert(MetadataDbHelper.METADATA_TABLE_NAME, null, values); + } + } + + /** + * An action that updates information about a word list - description, locale etc + */ + public static final class UpdateDataAction implements Action { + static final String TAG = "DictionaryProvider:" + UpdateDataAction.class.getSimpleName(); + private final String mClientId; + final WordListMetadata mWordList; + public UpdateDataAction(final String clientId, final WordListMetadata wordlist) { + DebugLogUtils.l("New UpdateData action for client ", clientId, " : ", wordlist); + mClientId = clientId; + mWordList = wordlist; + } + + @Override + public void execute(final Context context) { + if (null == mWordList) { // This should never happen + Log.e(TAG, "UpdateDataAction with a null word list!"); + return; + } + final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId); + ContentValues oldValues = MetadataDbHelper.getContentValuesByWordListId(db, + mWordList.mId, mWordList.mVersion); + if (null == oldValues) { + Log.e(TAG, "Trying to update data about a non-existing word list. Bailing out."); + return; + } + DebugLogUtils.l("Updating data about a word list : " + mWordList); + final ContentValues values = MetadataDbHelper.makeContentValues( + oldValues.getAsInteger(MetadataDbHelper.PENDINGID_COLUMN), + oldValues.getAsInteger(MetadataDbHelper.TYPE_COLUMN), + oldValues.getAsInteger(MetadataDbHelper.STATUS_COLUMN), + mWordList.mId, mWordList.mLocale, mWordList.mDescription, + oldValues.getAsString(MetadataDbHelper.LOCAL_FILENAME_COLUMN), + mWordList.mRemoteFilename, mWordList.mLastUpdate, mWordList.mRawChecksum, + mWordList.mChecksum, mWordList.mRetryCount, mWordList.mFileSize, + mWordList.mVersion, mWordList.mFormatVersion); + PrivateLog.log("Updating record for " + mWordList.mDescription + + " and locale " + mWordList.mLocale); + db.update(MetadataDbHelper.METADATA_TABLE_NAME, values, + MetadataDbHelper.WORDLISTID_COLUMN + " = ? AND " + + MetadataDbHelper.VERSION_COLUMN + " = ?", + new String[] { mWordList.mId, Integer.toString(mWordList.mVersion) }); + } + } + + /** + * An action that deletes the metadata about a word list if possible. + * + * This is triggered when a specific word list disappeared from the server, or when a fresher + * word list is available and the old one was not installed. + * If the word list has not been installed, it's possible to delete its associated metadata. + * Otherwise, the settings are retained so that the user can still administrate it. + */ + public static final class ForgetAction implements Action { + static final String TAG = "DictionaryProvider:" + ForgetAction.class.getSimpleName(); + private final String mClientId; + // The word list to remove. May not be null. + final WordListMetadata mWordList; + final boolean mHasNewerVersion; + public ForgetAction(final String clientId, final WordListMetadata wordlist, + final boolean hasNewerVersion) { + DebugLogUtils.l("New TryRemove action for client ", clientId, " : ", wordlist); + mClientId = clientId; + mWordList = wordlist; + mHasNewerVersion = hasNewerVersion; + } + + @Override + public void execute(final Context context) { + if (null == mWordList) { // This should never happen + Log.e(TAG, "TryRemoveAction with a null word list!"); + return; + } + DebugLogUtils.l("Trying to remove word list : " + mWordList); + final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId); + final ContentValues values = MetadataDbHelper.getContentValuesByWordListId(db, + mWordList.mId, mWordList.mVersion); + if (null == values) { + Log.e(TAG, "Trying to update the metadata of a non-existing wordlist. Cancelling."); + return; + } + final int status = values.getAsInteger(MetadataDbHelper.STATUS_COLUMN); + if (mHasNewerVersion && MetadataDbHelper.STATUS_AVAILABLE != status) { + // If we have a newer version of this word list, we should be here ONLY if it was + // not installed - else we should be upgrading it. + Log.e(TAG, "Unexpected status for forgetting a word list info : " + status + + ", removing URL to prevent re-download"); + } + if (MetadataDbHelper.STATUS_INSTALLED == status + || MetadataDbHelper.STATUS_DISABLED == status + || MetadataDbHelper.STATUS_DELETING == status) { + // If it is installed or disabled, we need to mark it as deleted so that LatinIME + // will remove it next time it enquires for dictionaries. + // If it is deleting and we don't have a new version, then we have to wait until + // LatinIME actually has deleted it before we can remove its metadata. + // In both cases, remove the URI from the database since it is not supposed to + // be accessible any more. + values.put(MetadataDbHelper.REMOTE_FILENAME_COLUMN, ""); + values.put(MetadataDbHelper.STATUS_COLUMN, MetadataDbHelper.STATUS_DELETING); + db.update(MetadataDbHelper.METADATA_TABLE_NAME, values, + MetadataDbHelper.WORDLISTID_COLUMN + " = ? AND " + + MetadataDbHelper.VERSION_COLUMN + " = ?", + new String[] { mWordList.mId, Integer.toString(mWordList.mVersion) }); + } else { + // If it's AVAILABLE or DOWNLOADING or even UNKNOWN, delete the entry. + db.delete(MetadataDbHelper.METADATA_TABLE_NAME, + MetadataDbHelper.WORDLISTID_COLUMN + " = ? AND " + + MetadataDbHelper.VERSION_COLUMN + " = ?", + new String[] { mWordList.mId, Integer.toString(mWordList.mVersion) }); + } + } + } + + /** + * An action that sets the word list for deletion as soon as possible. + * + * This is triggered when the user requests deletion of a word list. This will mark it as + * deleted in the database, and fire an intent for Kelar Keyboard to take notice and + * reload its dictionaries right away if it is up. If it is not up now, then it will + * delete the actual file the next time it gets up. + * A file marked as deleted causes the content provider to supply a zero-sized file to + * Kelar Keyboard, which will overwrite any existing file and provide no words for this + * word list. This is not exactly a "deletion", since there is an actual file which takes up + * a few bytes on the disk, but this allows to override a default dictionary with an empty + * dictionary. This way, there is no need for the user to make a distinction between + * dictionaries installed by default and add-on dictionaries. + */ + public static final class StartDeleteAction implements Action { + static final String TAG = "DictionaryProvider:" + StartDeleteAction.class.getSimpleName(); + private final String mClientId; + // The word list to delete. May not be null. + final WordListMetadata mWordList; + public StartDeleteAction(final String clientId, final WordListMetadata wordlist) { + DebugLogUtils.l("New StartDelete action for client ", clientId, " : ", wordlist); + mClientId = clientId; + mWordList = wordlist; + } + + @Override + public void execute(final Context context) { + if (null == mWordList) { // This should never happen + Log.e(TAG, "StartDeleteAction with a null word list!"); + return; + } + DebugLogUtils.l("Trying to delete word list : " + mWordList); + final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId); + final ContentValues values = MetadataDbHelper.getContentValuesByWordListId(db, + mWordList.mId, mWordList.mVersion); + if (null == values) { + Log.e(TAG, "Trying to set a non-existing wordlist for removal. Cancelling."); + return; + } + final int status = values.getAsInteger(MetadataDbHelper.STATUS_COLUMN); + if (MetadataDbHelper.STATUS_DISABLED != status) { + Log.e(TAG, "Unexpected status for deleting a word list info : " + status); + } + MetadataDbHelper.markEntryAsDeleting(db, mWordList.mId, mWordList.mVersion); + } + } + + /** + * An action that validates a word list as deleted. + * + * This will restore the word list as available if it still is, or remove the entry if + * it is not any more. + */ + public static final class FinishDeleteAction implements Action { + static final String TAG = "DictionaryProvider:" + FinishDeleteAction.class.getSimpleName(); + private final String mClientId; + // The word list to delete. May not be null. + final WordListMetadata mWordList; + public FinishDeleteAction(final String clientId, final WordListMetadata wordlist) { + DebugLogUtils.l("New FinishDelete action for client", clientId, " : ", wordlist); + mClientId = clientId; + mWordList = wordlist; + } + + @Override + public void execute(final Context context) { + if (null == mWordList) { // This should never happen + Log.e(TAG, "FinishDeleteAction with a null word list!"); + return; + } + DebugLogUtils.l("Trying to delete word list : " + mWordList); + final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId); + final ContentValues values = MetadataDbHelper.getContentValuesByWordListId(db, + mWordList.mId, mWordList.mVersion); + if (null == values) { + Log.e(TAG, "Trying to set a non-existing wordlist for removal. Cancelling."); + return; + } + final int status = values.getAsInteger(MetadataDbHelper.STATUS_COLUMN); + if (MetadataDbHelper.STATUS_DELETING != status) { + Log.e(TAG, "Unexpected status for finish-deleting a word list info : " + status); + } + final String remoteFilename = + values.getAsString(MetadataDbHelper.REMOTE_FILENAME_COLUMN); + // If there isn't a remote filename any more, then we don't know where to get the file + // from any more, so we remove the entry entirely. As a matter of fact, if the file was + // marked DELETING but disappeared from the metadata on the server, it ended up + // this way. + if (TextUtils.isEmpty(remoteFilename)) { + db.delete(MetadataDbHelper.METADATA_TABLE_NAME, + MetadataDbHelper.WORDLISTID_COLUMN + " = ? AND " + + MetadataDbHelper.VERSION_COLUMN + " = ?", + new String[] { mWordList.mId, Integer.toString(mWordList.mVersion) }); + } else { + MetadataDbHelper.markEntryAsAvailable(db, mWordList.mId, mWordList.mVersion); + } + } + } + + // An action batch consists of an ordered queue of Actions that can execute. + private final Queue<Action> mActions; + + public ActionBatch() { + mActions = new LinkedList<>(); + } + + public void add(final Action a) { + mActions.add(a); + } + + /** + * Append all the actions of another action batch. + * @param that the upgrade to merge into this one. + */ + public void append(final ActionBatch that) { + for (final Action a : that.mActions) { + add(a); + } + } + + /** + * Execute this batch. + * + * @param context the context for getting resources, databases, system services. + * @param reporter a Reporter to send errors to. + */ + public void execute(final Context context, final ProblemReporter reporter) { + DebugLogUtils.l("Executing a batch of actions"); + Queue<Action> remainingActions = mActions; + while (!remainingActions.isEmpty()) { + final Action a = remainingActions.poll(); + try { + a.execute(context); + } catch (Exception e) { + if (null != reporter) + reporter.report(e); + } + } + } +} diff --git a/java/src/org/kelar/inputmethod/dictionarypack/AssetFileAddress.java b/java/src/org/kelar/inputmethod/dictionarypack/AssetFileAddress.java new file mode 100644 index 000000000..dd81acfaf --- /dev/null +++ b/java/src/org/kelar/inputmethod/dictionarypack/AssetFileAddress.java @@ -0,0 +1,66 @@ +/* + * 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 java.io.File; + +/** + * Immutable class to hold the address of an asset. + * As opposed to a normal file, an asset is usually represented as a contiguous byte array in + * the package file. Open it correctly thus requires the name of the package it is in, but + * also the offset in the file and the length of this data. This class encapsulates these three. + */ +final class AssetFileAddress { + public final String mFilename; + public final long mOffset; + public final long mLength; + + public AssetFileAddress(final String filename, final long offset, final long length) { + mFilename = filename; + mOffset = offset; + mLength = length; + } + + /** + * Makes an AssetFileAddress. This may return null. + * + * @param filename the filename. + * @return the address, or null if the file does not exist or the parameters are not valid. + */ + public static AssetFileAddress makeFromFileName(final String filename) { + if (null == filename) return null; + final File f = new File(filename); + if (!f.isFile()) return null; + return new AssetFileAddress(filename, 0l, f.length()); + } + + /** + * Makes an AssetFileAddress. This may return null. + * + * @param filename the filename. + * @param offset the offset. + * @param length the length. + * @return the address, or null if the file does not exist or the parameters are not valid. + */ + public static AssetFileAddress makeFromFileNameAndOffset(final String filename, + final long offset, final long length) { + if (null == filename) return null; + final File f = new File(filename); + if (!f.isFile()) return null; + return new AssetFileAddress(filename, offset, length); + } +} diff --git a/java/src/org/kelar/inputmethod/dictionarypack/BadFormatException.java b/java/src/org/kelar/inputmethod/dictionarypack/BadFormatException.java new file mode 100644 index 000000000..de884d10a --- /dev/null +++ b/java/src/org/kelar/inputmethod/dictionarypack/BadFormatException.java @@ -0,0 +1,30 @@ +/* + * 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; + +/** + * Exception thrown when the metadata for the dictionary does not comply to a known format. + */ +public final class BadFormatException extends Exception { + public BadFormatException() { + super(); + } + + public BadFormatException(final String message) { + super(message); + } +} diff --git a/java/src/org/kelar/inputmethod/dictionarypack/ButtonSwitcher.java b/java/src/org/kelar/inputmethod/dictionarypack/ButtonSwitcher.java new file mode 100644 index 000000000..46692559e --- /dev/null +++ b/java/src/org/kelar/inputmethod/dictionarypack/ButtonSwitcher.java @@ -0,0 +1,170 @@ +/** + * Copyright (C) 2013 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.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewPropertyAnimator; +import android.widget.Button; +import android.widget.FrameLayout; + +import org.kelar.inputmethod.latin.R; + +/** + * A view that handles buttons inside it according to a status. + */ +public class ButtonSwitcher extends FrameLayout { + public static final int NOT_INITIALIZED = -1; + public static final int STATUS_NO_BUTTON = 0; + public static final int STATUS_INSTALL = 1; + public static final int STATUS_CANCEL = 2; + public static final int STATUS_DELETE = 3; + // One of the above + private int mStatus = NOT_INITIALIZED; + private int mAnimateToStatus = NOT_INITIALIZED; + + // Animation directions + public static final int ANIMATION_IN = 1; + public static final int ANIMATION_OUT = 2; + + private Button mInstallButton; + private Button mCancelButton; + private Button mDeleteButton; + private DictionaryListInterfaceState mInterfaceState; + private OnClickListener mOnClickListener; + + public ButtonSwitcher(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public ButtonSwitcher(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + } + + public void reset(final DictionaryListInterfaceState interfaceState) { + mStatus = NOT_INITIALIZED; + mAnimateToStatus = NOT_INITIALIZED; + mInterfaceState = interfaceState; + } + + @Override + protected void onLayout(final boolean changed, final int left, final int top, final int right, + final int bottom) { + super.onLayout(changed, left, top, right, bottom); + mInstallButton = (Button)findViewById(R.id.dict_install_button); + mCancelButton = (Button)findViewById(R.id.dict_cancel_button); + mDeleteButton = (Button)findViewById(R.id.dict_delete_button); + setInternalOnClickListener(mOnClickListener); + setButtonPositionWithoutAnimation(mStatus); + if (mAnimateToStatus != NOT_INITIALIZED) { + // We have been asked to animate before we were ready, so we took a note of it. + // We are now ready: launch the animation. + animateButtonPosition(mStatus, mAnimateToStatus); + mStatus = mAnimateToStatus; + mAnimateToStatus = NOT_INITIALIZED; + } + } + + private Button getButton(final int status) { + switch(status) { + case STATUS_INSTALL: + return mInstallButton; + case STATUS_CANCEL: + return mCancelButton; + case STATUS_DELETE: + return mDeleteButton; + default: + return null; + } + } + + public void setStatusAndUpdateVisuals(final int status) { + if (mStatus == NOT_INITIALIZED) { + setButtonPositionWithoutAnimation(status); + mStatus = status; + } else { + if (null == mInstallButton) { + // We may come here before we have been layout. In this case we don't know our + // size yet so we can't start animations so we need to remember what animation to + // start once layout has gone through. + mAnimateToStatus = status; + } else { + animateButtonPosition(mStatus, status); + mStatus = status; + } + } + } + + private void setButtonPositionWithoutAnimation(final int status) { + // This may be called by setStatus() before the layout has come yet. + if (null == mInstallButton) return; + final int width = getWidth(); + // Set to out of the screen if that's not the currently displayed status + mInstallButton.setTranslationX(STATUS_INSTALL == status ? 0 : width); + mCancelButton.setTranslationX(STATUS_CANCEL == status ? 0 : width); + mDeleteButton.setTranslationX(STATUS_DELETE == status ? 0 : width); + } + + // The helper method for {@link AnimatorListenerAdapter}. + void animateButtonIfStatusIsEqual(final View newButton, final int newStatus) { + if (newStatus != mStatus) return; + animateButton(newButton, ANIMATION_IN); + } + + private void animateButtonPosition(final int oldStatus, final int newStatus) { + final View oldButton = getButton(oldStatus); + final View newButton = getButton(newStatus); + if (null != oldButton && null != newButton) { + // Transition between two buttons : animate out, then in + animateButton(oldButton, ANIMATION_OUT).setListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(final Animator animation) { + animateButtonIfStatusIsEqual(newButton, newStatus); + } + }); + } else if (null != oldButton) { + animateButton(oldButton, ANIMATION_OUT); + } else if (null != newButton) { + animateButton(newButton, ANIMATION_IN); + } + } + + public void setInternalOnClickListener(final OnClickListener listener) { + mOnClickListener = listener; + if (null != mInstallButton) { + // Already laid out : do it now + mInstallButton.setOnClickListener(mOnClickListener); + mCancelButton.setOnClickListener(mOnClickListener); + mDeleteButton.setOnClickListener(mOnClickListener); + } + } + + private ViewPropertyAnimator animateButton(final View button, final int direction) { + final float outerX = getWidth(); + final float innerX = button.getX() - button.getTranslationX(); + mInterfaceState.removeFromCache((View)getParent()); + if (ANIMATION_IN == direction) { + button.setClickable(true); + return button.animate().translationX(0); + } + button.setClickable(false); + return button.animate().translationX(outerX - innerX); + } +} diff --git a/java/src/org/kelar/inputmethod/dictionarypack/CommonPreferences.java b/java/src/org/kelar/inputmethod/dictionarypack/CommonPreferences.java new file mode 100644 index 000000000..e4676b186 --- /dev/null +++ b/java/src/org/kelar/inputmethod/dictionarypack/CommonPreferences.java @@ -0,0 +1,40 @@ +/** + * 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.Context; +import android.content.SharedPreferences; + +public final class CommonPreferences { + private static final String COMMON_PREFERENCES_NAME = "LatinImeDictPrefs"; + + public static SharedPreferences getCommonPreferences(final Context context) { + return context.getSharedPreferences(COMMON_PREFERENCES_NAME, 0); + } + + public static void enable(final SharedPreferences pref, final String id) { + final SharedPreferences.Editor editor = pref.edit(); + editor.putBoolean(id, true); + editor.apply(); + } + + public static void disable(final SharedPreferences pref, final String id) { + final SharedPreferences.Editor editor = pref.edit(); + editor.putBoolean(id, false); + editor.apply(); + } +} diff --git a/java/src/org/kelar/inputmethod/dictionarypack/CompletedDownloadInfo.java b/java/src/org/kelar/inputmethod/dictionarypack/CompletedDownloadInfo.java new file mode 100644 index 000000000..aa55b4fe2 --- /dev/null +++ b/java/src/org/kelar/inputmethod/dictionarypack/CompletedDownloadInfo.java @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2013 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.app.DownloadManager; + +/** + * Struct class to encapsulate the result of a completed download. + */ +public class CompletedDownloadInfo { + final String mUri; + final long mDownloadId; + final int mStatus; + public CompletedDownloadInfo(final String uri, final long downloadId, final int status) { + mUri = uri; + mDownloadId = downloadId; + mStatus = status; + } + public boolean wasSuccessful() { + return DownloadManager.STATUS_SUCCESSFUL == mStatus; + } +} diff --git a/java/src/org/kelar/inputmethod/dictionarypack/DictionaryDownloadProgressBar.java b/java/src/org/kelar/inputmethod/dictionarypack/DictionaryDownloadProgressBar.java new file mode 100644 index 000000000..f0a591eca --- /dev/null +++ b/java/src/org/kelar/inputmethod/dictionarypack/DictionaryDownloadProgressBar.java @@ -0,0 +1,173 @@ +/** + * Copyright (C) 2013 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.app.DownloadManager; +import android.app.DownloadManager.Query; +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.os.Handler; +import android.util.AttributeSet; +import android.util.Log; +import android.view.View; +import android.widget.ProgressBar; + +public class DictionaryDownloadProgressBar extends ProgressBar { + private static final String TAG = DictionaryDownloadProgressBar.class.getSimpleName(); + private static final int NOT_A_DOWNLOADMANAGER_PENDING_ID = 0; + + private String mClientId; + private String mWordlistId; + private boolean mIsCurrentlyAttachedToWindow = false; + private Thread mReporterThread = null; + + public DictionaryDownloadProgressBar(final Context context) { + super(context); + } + + public DictionaryDownloadProgressBar(final Context context, final AttributeSet attrs) { + super(context, attrs); + } + + public void setIds(final String clientId, final String wordlistId) { + mClientId = clientId; + mWordlistId = wordlistId; + } + + static private int getDownloadManagerPendingIdFromWordlistId(final Context context, + final String clientId, final String wordlistId) { + final SQLiteDatabase db = MetadataDbHelper.getDb(context, clientId); + final ContentValues wordlistValues = + MetadataDbHelper.getContentValuesOfLatestAvailableWordlistById(db, wordlistId); + if (null == wordlistValues) { + // We don't know anything about a word list with this id. Bug? This should never + // happen, but still return to prevent a crash. + Log.e(TAG, "Unexpected word list ID: " + wordlistId); + return NOT_A_DOWNLOADMANAGER_PENDING_ID; + } + return wordlistValues.getAsInteger(MetadataDbHelper.PENDINGID_COLUMN); + } + + /* + * This method will stop any running updater thread for this progress bar and create and run + * a new one only if the progress bar is visible. + * Hence, as a result of calling this method, the progress bar will have an updater thread + * running if and only if the progress bar is visible. + */ + private void updateReporterThreadRunningStatusAccordingToVisibility() { + if (null != mReporterThread) mReporterThread.interrupt(); + if (mIsCurrentlyAttachedToWindow && View.VISIBLE == getVisibility()) { + final int downloadManagerPendingId = + getDownloadManagerPendingIdFromWordlistId(getContext(), mClientId, mWordlistId); + if (NOT_A_DOWNLOADMANAGER_PENDING_ID == downloadManagerPendingId) { + // Can't get the ID. This is never supposed to happen, but still clear the updater + // thread and return to avoid a crash. + mReporterThread = null; + return; + } + final UpdaterThread updaterThread = + new UpdaterThread(getContext(), downloadManagerPendingId); + updaterThread.start(); + mReporterThread = updaterThread; + } else { + // We're not going to restart the thread anyway, so we may as well garbage collect it. + mReporterThread = null; + } + } + + @Override + protected void onAttachedToWindow() { + mIsCurrentlyAttachedToWindow = true; + updateReporterThreadRunningStatusAccordingToVisibility(); + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + mIsCurrentlyAttachedToWindow = false; + updateReporterThreadRunningStatusAccordingToVisibility(); + } + + private class UpdaterThread extends Thread { + private final static int REPORT_PERIOD = 150; // how often to report progress, in ms + final DownloadManagerWrapper mDownloadManagerWrapper; + final int mId; + public UpdaterThread(final Context context, final int id) { + super(); + mDownloadManagerWrapper = new DownloadManagerWrapper(context); + mId = id; + } + @Override + public void run() { + try { + final UpdateHelper updateHelper = new UpdateHelper(); + final Query query = new Query().setFilterById(mId); + setIndeterminate(true); + while (!isInterrupted()) { + final Cursor cursor = mDownloadManagerWrapper.query(query); + if (null == cursor) { + // Can't contact DownloadManager: this should never happen. + return; + } + try { + if (cursor.moveToNext()) { + final int columnBytesDownloadedSoFar = cursor.getColumnIndex( + DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR); + final int bytesDownloadedSoFar = + cursor.getInt(columnBytesDownloadedSoFar); + updateHelper.setProgressFromAnotherThread(bytesDownloadedSoFar); + } else { + // Download has finished and DownloadManager has already been asked to + // clean up the db entry. + updateHelper.setProgressFromAnotherThread(getMax()); + return; + } + } finally { + cursor.close(); + } + Thread.sleep(REPORT_PERIOD); + } + } catch (InterruptedException e) { + // Do nothing and terminate normally. + } + } + + class UpdateHelper implements Runnable { + private int mProgress; + @Override + public void run() { + setIndeterminate(false); + setProgress(mProgress); + } + public void setProgressFromAnotherThread(final int progress) { + if (mProgress != progress) { + mProgress = progress; + // For some unknown reason, setProgress just does not work from a separate + // thread, although the code in ProgressBar looks like it should. Thus, we + // resort to a runnable posted to the handler of the view. + final Handler handler = getHandler(); + // It's possible to come here before this view has been laid out. If so, + // just ignore the call - it will be updated again later. + if (null == handler) return; + handler.post(this); + } + } + } + } +} diff --git a/java/src/org/kelar/inputmethod/dictionarypack/DictionaryListInterfaceState.java b/java/src/org/kelar/inputmethod/dictionarypack/DictionaryListInterfaceState.java new file mode 100644 index 000000000..3e469bd2c --- /dev/null +++ b/java/src/org/kelar/inputmethod/dictionarypack/DictionaryListInterfaceState.java @@ -0,0 +1,85 @@ +/** + * Copyright (C) 2013 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.view.View; + +import java.util.ArrayList; +import java.util.HashMap; + +/** + * Helper class to maintain the interface state of word list preferences. + * + * This is necessary because the views are created on-demand by calling code. There are many + * situations where views are renewed with little relation with user interaction. For example, + * when scrolling, the view is reused so it doesn't keep its state, which means we need to keep + * it separately. Also whenever the underlying dictionary list undergoes a change (for example, + * update the metadata, or finish downloading) the whole list has to be thrown out and recreated + * in case some dictionaries appeared, disappeared, changed states etc. + */ +public class DictionaryListInterfaceState { + static class State { + public boolean mOpen = false; + public int mStatus = MetadataDbHelper.STATUS_UNKNOWN; + } + + private HashMap<String, State> mWordlistToState = new HashMap<>(); + private ArrayList<View> mViewCache = new ArrayList<>(); + + public boolean isOpen(final String wordlistId) { + final State state = mWordlistToState.get(wordlistId); + if (null == state) return false; + return state.mOpen; + } + + public int getStatus(final String wordlistId) { + final State state = mWordlistToState.get(wordlistId); + if (null == state) return MetadataDbHelper.STATUS_UNKNOWN; + return state.mStatus; + } + + public void setOpen(final String wordlistId, final int status) { + final State newState; + final State state = mWordlistToState.get(wordlistId); + newState = null == state ? new State() : state; + newState.mOpen = true; + newState.mStatus = status; + mWordlistToState.put(wordlistId, newState); + } + + public void closeAll() { + for (final State state : mWordlistToState.values()) { + state.mOpen = false; + } + } + + public View findFirstOrphanedView() { + for (final View v : mViewCache) { + if (null == v.getParent()) return v; + } + return null; + } + + public View addToCacheAndReturnView(final View view) { + mViewCache.add(view); + return view; + } + + public void removeFromCache(final View view) { + mViewCache.remove(view); + } +} diff --git a/java/src/org/kelar/inputmethod/dictionarypack/DictionaryPackConstants.java b/java/src/org/kelar/inputmethod/dictionarypack/DictionaryPackConstants.java new file mode 100644 index 000000000..6f9b5be7d --- /dev/null +++ b/java/src/org/kelar/inputmethod/dictionarypack/DictionaryPackConstants.java @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2013 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; + +/** + * A class to group constants for dictionary pack usage. + * + * This class only defines constants. It should not make any references to outside code as far as + * possible, as it's used to separate cleanly the keyboard code from the dictionary pack code; this + * is needed in particular to cleanly compile regression tests. + */ +public class DictionaryPackConstants { + /** + * The root domain for the dictionary pack, upon which authorities and actions will append + * their own distinctive strings. + */ + private static final String DICTIONARY_DOMAIN = "org.kelar.inputmethod.dictionarypack.aosp"; + + /** + * Authority for the ContentProvider protocol. + */ + // TODO: find some way to factorize this string with the one in the resources + public static final String AUTHORITY = DICTIONARY_DOMAIN; + + /** + * The action of the intent for publishing that new dictionary data is available. + */ + // TODO: make this different across different packages. A suggested course of action is + // to use the package name inside this string. + // NOTE: The appended string should be uppercase like all other actions, but it's not for + // historical reasons. + public static final String NEW_DICTIONARY_INTENT_ACTION = DICTIONARY_DOMAIN + ".newdict"; + + /** + * The action of the intent sent by the dictionary pack to ask for a client to make + * itself known. This is used when the settings activity is brought up for a client the + * dictionary pack does not know about. + */ + public static final String UNKNOWN_DICTIONARY_PROVIDER_CLIENT = DICTIONARY_DOMAIN + + ".UNKNOWN_CLIENT"; + + // In the above intents, the name of the string extra that contains the name of the client + // we want information about. + public static final String DICTIONARY_PROVIDER_CLIENT_EXTRA = "client"; + + /** + * The action of the intent to tell the dictionary provider to update now. + */ + public static final String UPDATE_NOW_INTENT_ACTION = DICTIONARY_DOMAIN + + ".UPDATE_NOW"; + + /** + * The intent action to inform the dictionary provider to initialize the db + * and update now. + */ + public static final String INIT_AND_UPDATE_NOW_INTENT_ACTION = DICTIONARY_DOMAIN + + ".INIT_AND_UPDATE_NOW"; +} 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<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; + 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<WordListInfo> 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.<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 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<WordListInfo> getDictionaryWordListsForLocale(final String clientId, + final String locale) { + final Context context = getContext(); + final Cursor results = + MetadataDbHelper.queryInstalledOrDeletingOrAvailableDictionaryMetadata(context, + clientId); + if (null == results) { + return Collections.<WordListInfo>emptyList(); + } + try { + final HashMap<String, WordListInfo> 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"); + } +} diff --git a/java/src/org/kelar/inputmethod/dictionarypack/DictionaryService.java b/java/src/org/kelar/inputmethod/dictionarypack/DictionaryService.java new file mode 100644 index 000000000..851a1d925 --- /dev/null +++ b/java/src/org/kelar/inputmethod/dictionarypack/DictionaryService.java @@ -0,0 +1,280 @@ +/** + * 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.app.AlarmManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.os.IBinder; +import android.util.Log; +import android.widget.Toast; + +import org.kelar.inputmethod.latin.BinaryDictionaryFileDumper; +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.common.LocaleUtils; + +import java.util.Locale; +import java.util.Random; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; + +import javax.annotation.Nonnull; + +/** + * Service that handles background tasks for the dictionary provider. + * + * This service provides the context for the long-running operations done by the + * dictionary provider. Those include: + * - Checking for the last update date and scheduling the next update. This runs every + * day around midnight, upon reception of the DATE_CHANGED_INTENT_ACTION broadcast. + * Every four days, it schedules an update of the metadata with the alarm manager. + * - Issuing the order to update the metadata. This runs every four days, between 0 and + * 6, upon reception of the UPDATE_NOW_INTENT_ACTION broadcast sent by the alarm manager + * as a result of the above action. + * - Handling a download that just ended. These come in two flavors: + * - Metadata is finished downloading. We should check whether there are new dictionaries + * available, and download those that we need that have new versions. + * - A dictionary file finished downloading. We should put the file ready for a client IME + * to access, and mark the current state as such. + */ +public final class DictionaryService extends Service { + private static final String TAG = DictionaryService.class.getSimpleName(); + + /** + * The package name, to use in the intent actions. + */ + private static final String PACKAGE_NAME = "org.kelar.inputmethod.latin"; + + /** + * The action of the date changing, used to schedule a periodic freshness check + */ + private static final String DATE_CHANGED_INTENT_ACTION = + Intent.ACTION_DATE_CHANGED; + + /** + * The action of displaying a toast to warn the user an automatic download is starting. + */ + /* package */ static final String SHOW_DOWNLOAD_TOAST_INTENT_ACTION = + PACKAGE_NAME + ".SHOW_DOWNLOAD_TOAST_INTENT_ACTION"; + + /** + * A locale argument, as a String. + */ + /* package */ static final String LOCALE_INTENT_ARGUMENT = "locale"; + + /** + * How often, in milliseconds, we want to update the metadata. This is a + * floor value; actually, it may happen several hours later, or even more. + */ + private static final long UPDATE_FREQUENCY_MILLIS = TimeUnit.DAYS.toMillis(4); + + /** + * We are waked around midnight, local time. We want to wake between midnight and 6 am, + * roughly. So use a random time between 0 and this delay. + */ + private static final int MAX_ALARM_DELAY_MILLIS = (int)TimeUnit.HOURS.toMillis(6); + + /** + * How long we consider a "very long time". If no update took place in this time, + * the content provider will trigger an update in the background. + */ + private static final long VERY_LONG_TIME_MILLIS = TimeUnit.DAYS.toMillis(14); + + /** + * After starting a download, how long we wait before considering it may be stuck. After this + * period is elapsed, if the keyboard tries to download again, then we cancel and re-register + * the request; if it's within this time, we just leave it be. + * It's important to note that we do not re-submit the request merely because the time is up. + * This is only to decide whether to cancel the old one and re-requesting when the keyboard + * fires a new request for the same data. + */ + public static final long NO_CANCEL_DOWNLOAD_PERIOD_MILLIS = TimeUnit.SECONDS.toMillis(30); + + /** + * An executor that serializes tasks given to it. + */ + private ThreadPoolExecutor mExecutor; + private static final int WORKER_THREAD_TIMEOUT_SECONDS = 15; + + @Override + public void onCreate() { + // By default, a thread pool executor does not timeout its core threads, so it will + // never kill them when there isn't any work to do any more. That would mean the service + // can never die! By creating it this way and calling allowCoreThreadTimeOut, we allow + // the single thread to time out after WORKER_THREAD_TIMEOUT_SECONDS = 15 seconds, allowing + // the process to be reclaimed by the system any time after that if it's not doing + // anything else. + // Executors#newSingleThreadExecutor creates a ThreadPoolExecutor but it returns the + // superclass ExecutorService which does not have the #allowCoreThreadTimeOut method, + // so we can't use that. + mExecutor = new ThreadPoolExecutor(1 /* corePoolSize */, 1 /* maximumPoolSize */, + WORKER_THREAD_TIMEOUT_SECONDS /* keepAliveTime */, + TimeUnit.SECONDS /* unit for keepAliveTime */, + new LinkedBlockingQueue<Runnable>() /* workQueue */); + mExecutor.allowCoreThreadTimeOut(true); + } + + @Override + public void onDestroy() { + } + + @Override + public IBinder onBind(Intent intent) { + // This service cannot be bound + return null; + } + + /** + * Executes an explicit command. + * + * This is the entry point for arbitrary commands that are executed upon reception of certain + * events that should be executed on the context of this service. The supported commands are: + * - Check last update time and possibly schedule an update of the data for later. + * This is triggered every day, upon reception of the DATE_CHANGED_INTENT_ACTION broadcast. + * - Update data NOW. + * This is normally received upon trigger of the scheduled update. + * - Handle a finished download. + * This executes the actions that must be taken after a file (metadata or dictionary data + * has been downloaded (or failed to download). + * The commands that can be spun an another thread will be executed serially, in order, on + * a worker thread that is created on demand and terminates after a short while if there isn't + * any work left to do. + */ + @Override + public synchronized int onStartCommand(final Intent intent, final int flags, + final int startId) { + final DictionaryService self = this; + if (SHOW_DOWNLOAD_TOAST_INTENT_ACTION.equals(intent.getAction())) { + final String localeString = intent.getStringExtra(LOCALE_INTENT_ARGUMENT); + if (localeString == null) { + Log.e(TAG, "Received " + intent.getAction() + " without locale; skipped"); + } else { + // This is a UI action, it can't be run in another thread + showStartDownloadingToast( + this, LocaleUtils.constructLocaleFromString(localeString)); + } + } else { + // If it's a command that does not require UI, arrange for the work to be done on a + // separate thread, so that we can return right away. The executor will spawn a thread + // if necessary, or reuse a thread that has become idle as appropriate. + // DATE_CHANGED or UPDATE_NOW are examples of commands that can be done on another + // thread. + mExecutor.submit(new Runnable() { + @Override + public void run() { + dispatchBroadcast(self, intent); + // Since calls to onStartCommand are serialized, the submissions to the executor + // are serialized. That means we are guaranteed to call the stopSelfResult() + // in the same order that we got them, so we don't need to take care of the + // order. + stopSelfResult(startId); + } + }); + } + return Service.START_REDELIVER_INTENT; + } + + static void dispatchBroadcast(final Context context, final Intent intent) { + final String action = intent.getAction(); + if (DATE_CHANGED_INTENT_ACTION.equals(action)) { + // This happens when the date of the device changes. This normally happens + // at midnight local time, but it may happen if the user changes the date + // by hand or something similar happens. + checkTimeAndMaybeSetupUpdateAlarm(context); + } else if (DictionaryPackConstants.UPDATE_NOW_INTENT_ACTION.equals(action)) { + // Intent to trigger an update now. + UpdateHandler.tryUpdate(context); + } else if (DictionaryPackConstants.INIT_AND_UPDATE_NOW_INTENT_ACTION.equals(action)) { + // Initialize the client Db. + final String mClientId = context.getString(R.string.dictionary_pack_client_id); + BinaryDictionaryFileDumper.initializeClientRecordHelper(context, mClientId); + + // Updates the metadata and the download the dictionaries. + UpdateHandler.tryUpdate(context); + } else { + UpdateHandler.downloadFinished(context, intent); + } + } + + /** + * Setups an alarm to check for updates if an update is due. + */ + private static void checkTimeAndMaybeSetupUpdateAlarm(final Context context) { + // Of all clients, if the one that hasn't been updated for the longest + // is still more recent than UPDATE_FREQUENCY_MILLIS, do nothing. + if (!isLastUpdateAtLeastThisOld(context, UPDATE_FREQUENCY_MILLIS)) return; + + PrivateLog.log("Date changed - registering alarm"); + AlarmManager alarmManager = (AlarmManager)context.getSystemService(Context.ALARM_SERVICE); + + // Best effort to wake between midnight and MAX_ALARM_DELAY_MILLIS in the morning. + // It doesn't matter too much if this is very inexact. + final long now = System.currentTimeMillis(); + final long alarmTime = now + new Random().nextInt(MAX_ALARM_DELAY_MILLIS); + final Intent updateIntent = new Intent(DictionaryPackConstants.UPDATE_NOW_INTENT_ACTION); + // Set the package name to ensure the PendingIntent is only delivered to trusted components + updateIntent.setPackage(context.getPackageName()); + int pendingIntentFlags = PendingIntent.FLAG_CANCEL_CURRENT; + if (android.os.Build.VERSION.SDK_INT >= 23) { + pendingIntentFlags |= PendingIntent.FLAG_IMMUTABLE; + } + final PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, + updateIntent, pendingIntentFlags); + + // We set the alarm in the type that doesn't forcefully wake the device + // from sleep, but fires the next time the device actually wakes for any + // other reason. + if (null != alarmManager) alarmManager.set(AlarmManager.RTC, alarmTime, pendingIntent); + } + + /** + * Utility method to decide whether the last update is older than a certain time. + * + * @return true if at least `time' milliseconds have elapsed since last update, false otherwise. + */ + private static boolean isLastUpdateAtLeastThisOld(final Context context, final long time) { + final long now = System.currentTimeMillis(); + final long lastUpdate = MetadataDbHelper.getOldestUpdateTime(context); + PrivateLog.log("Last update was " + lastUpdate); + return lastUpdate + time < now; + } + + /** + * Refreshes data if it hasn't been refreshed in a very long time. + * + * This will check the last update time, and if it's been more than VERY_LONG_TIME_MILLIS, + * update metadata now - and possibly take subsequent update actions. + */ + public static void updateNowIfNotUpdatedInAVeryLongTime(final Context context) { + if (!isLastUpdateAtLeastThisOld(context, VERY_LONG_TIME_MILLIS)) return; + UpdateHandler.tryUpdate(context); + } + + /** + * Shows a toast informing the user that an automatic dictionary download is starting. + */ + private static void showStartDownloadingToast(final Context context, + @Nonnull final Locale locale) { + final String toastText = String.format( + context.getString(R.string.toast_downloading_suggestions), + locale.getDisplayName()); + Toast.makeText(context, toastText, Toast.LENGTH_LONG).show(); + } +} diff --git a/java/src/org/kelar/inputmethod/dictionarypack/DictionarySettingsActivity.java b/java/src/org/kelar/inputmethod/dictionarypack/DictionarySettingsActivity.java new file mode 100644 index 000000000..f86eda177 --- /dev/null +++ b/java/src/org/kelar/inputmethod/dictionarypack/DictionarySettingsActivity.java @@ -0,0 +1,54 @@ +/** + * 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 org.kelar.inputmethod.latin.utils.FragmentUtils; + +import android.annotation.TargetApi; +import android.content.Intent; +import android.os.Build; +import android.os.Bundle; +import android.preference.PreferenceActivity; + +/** + * Preference screen. + */ +public final class DictionarySettingsActivity extends PreferenceActivity { + private static final String DEFAULT_FRAGMENT = DictionarySettingsFragment.class.getName(); + + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + } + + @Override + public Intent getIntent() { + final Intent modIntent = new Intent(super.getIntent()); + modIntent.putExtra(EXTRA_SHOW_FRAGMENT, DEFAULT_FRAGMENT); + modIntent.putExtra(EXTRA_NO_HEADERS, true); + // Important note : the original intent should contain a String extra with the key + // DictionarySettingsFragment.DICT_SETTINGS_FRAGMENT_CLIENT_ID_ARGUMENT so that the + // fragment can know who the client is. + return modIntent; + } + + @TargetApi(Build.VERSION_CODES.KITKAT) + @Override + public boolean isValidFragment(String fragmentName) { + return FragmentUtils.isValidFragment(fragmentName); + } +} diff --git a/java/src/org/kelar/inputmethod/dictionarypack/DictionarySettingsFragment.java b/java/src/org/kelar/inputmethod/dictionarypack/DictionarySettingsFragment.java new file mode 100644 index 000000000..a4783a78f --- /dev/null +++ b/java/src/org/kelar/inputmethod/dictionarypack/DictionarySettingsFragment.java @@ -0,0 +1,438 @@ +/** + * 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 org.kelar.inputmethod.latin.common.LocaleUtils; + +import android.app.Activity; +import android.content.BroadcastReceiver; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.database.Cursor; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Bundle; +import android.preference.Preference; +import android.preference.PreferenceFragment; +import android.preference.PreferenceGroup; +import android.text.TextUtils; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.AnimationUtils; + +import org.kelar.inputmethod.latin.R; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Locale; +import java.util.TreeMap; + +/** + * Preference screen. + */ +public final class DictionarySettingsFragment extends PreferenceFragment + implements UpdateHandler.UpdateEventListener { + private static final String TAG = DictionarySettingsFragment.class.getSimpleName(); + + static final private String DICT_LIST_ID = "list"; + static final public String DICT_SETTINGS_FRAGMENT_CLIENT_ID_ARGUMENT = "clientId"; + + static final private int MENU_UPDATE_NOW = Menu.FIRST; + + private View mLoadingView; + private String mClientId; + private ConnectivityManager mConnectivityManager; + private MenuItem mUpdateNowMenu; + private boolean mChangedSettings; + private DictionaryListInterfaceState mDictionaryListInterfaceState = + new DictionaryListInterfaceState(); + // never null + private TreeMap<String, WordListPreference> mCurrentPreferenceMap = new TreeMap<>(); + + private final BroadcastReceiver mConnectivityChangedReceiver = new BroadcastReceiver() { + @Override + public void onReceive(final Context context, final Intent intent) { + refreshNetworkState(); + } + }; + + /** + * Empty constructor for fragment generation. + */ + public DictionarySettingsFragment() { + } + + @Override + public View onCreateView(final LayoutInflater inflater, final ViewGroup container, + final Bundle savedInstanceState) { + final View v = inflater.inflate(R.layout.loading_page, container, true); + mLoadingView = v.findViewById(R.id.loading_container); + return super.onCreateView(inflater, container, savedInstanceState); + } + + @Override + public void onActivityCreated(final Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + final Activity activity = getActivity(); + mClientId = activity.getIntent().getStringExtra(DICT_SETTINGS_FRAGMENT_CLIENT_ID_ARGUMENT); + mConnectivityManager = + (ConnectivityManager)activity.getSystemService(Context.CONNECTIVITY_SERVICE); + addPreferencesFromResource(R.xml.dictionary_settings); + refreshInterface(); + setHasOptionsMenu(true); + } + + @Override + public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { + new AsyncTask<Void, Void, String>() { + @Override + protected String doInBackground(Void... params) { + return MetadataDbHelper.getMetadataUriAsString(getActivity(), mClientId); + } + + @Override + protected void onPostExecute(String metadataUri) { + // We only add the "Refresh" button if we have a non-empty URL to refresh from. If + // the URL is empty, of course we can't refresh so it makes no sense to display + // this. + if (!TextUtils.isEmpty(metadataUri)) { + if (mUpdateNowMenu == null) { + mUpdateNowMenu = menu.add(Menu.NONE, MENU_UPDATE_NOW, 0, + R.string.check_for_updates_now); + mUpdateNowMenu.setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM); + } + refreshNetworkState(); + } + } + }.execute(); + } + + @Override + public void onResume() { + super.onResume(); + mChangedSettings = false; + UpdateHandler.registerUpdateEventListener(this); + final Activity activity = getActivity(); + final IntentFilter filter = new IntentFilter(); + filter.addAction(ConnectivityManager.CONNECTIVITY_ACTION); + getActivity().registerReceiver(mConnectivityChangedReceiver, filter); + refreshNetworkState(); + + new Thread("onResume") { + @Override + public void run() { + if (!MetadataDbHelper.isClientKnown(activity, mClientId)) { + Log.i(TAG, "Unknown dictionary pack client: " + mClientId + + ". Requesting info."); + final Intent unknownClientBroadcast = + new Intent(DictionaryPackConstants.UNKNOWN_DICTIONARY_PROVIDER_CLIENT); + unknownClientBroadcast.putExtra( + DictionaryPackConstants.DICTIONARY_PROVIDER_CLIENT_EXTRA, mClientId); + activity.sendBroadcast(unknownClientBroadcast); + } + } + }.start(); + } + + @Override + public void onPause() { + super.onPause(); + final Activity activity = getActivity(); + UpdateHandler.unregisterUpdateEventListener(this); + activity.unregisterReceiver(mConnectivityChangedReceiver); + if (mChangedSettings) { + final Intent newDictBroadcast = + new Intent(DictionaryPackConstants.NEW_DICTIONARY_INTENT_ACTION); + activity.sendBroadcast(newDictBroadcast); + mChangedSettings = false; + } + } + + @Override + public void downloadedMetadata(final boolean succeeded) { + stopLoadingAnimation(); + if (!succeeded) return; // If the download failed nothing changed, so no need to refresh + new Thread("refreshInterface") { + @Override + public void run() { + refreshInterface(); + } + }.start(); + } + + @Override + public void wordListDownloadFinished(final String wordListId, final boolean succeeded) { + final WordListPreference pref = findWordListPreference(wordListId); + if (null == pref) return; + // TODO: Report to the user if !succeeded + final Activity activity = getActivity(); + if (null == activity) return; + activity.runOnUiThread(new Runnable() { + @Override + public void run() { + // We have to re-read the db in case the description has changed, and to + // find out what state it ended up if the download wasn't successful + // TODO: don't redo everything, only re-read and set this word list status + refreshInterface(); + } + }); + } + + private WordListPreference findWordListPreference(final String id) { + final PreferenceGroup prefScreen = getPreferenceScreen(); + if (null == prefScreen) { + Log.e(TAG, "Could not find the preference group"); + return null; + } + for (int i = prefScreen.getPreferenceCount() - 1; i >= 0; --i) { + final Preference pref = prefScreen.getPreference(i); + if (pref instanceof WordListPreference) { + final WordListPreference wlPref = (WordListPreference)pref; + if (id.equals(wlPref.mWordlistId)) { + return wlPref; + } + } + } + Log.e(TAG, "Could not find the preference for a word list id " + id); + return null; + } + + @Override + public void updateCycleCompleted() {} + + void refreshNetworkState() { + NetworkInfo info = mConnectivityManager.getActiveNetworkInfo(); + boolean isConnected = null == info ? false : info.isConnected(); + if (null != mUpdateNowMenu) mUpdateNowMenu.setEnabled(isConnected); + } + + void refreshInterface() { + final Activity activity = getActivity(); + if (null == activity) return; + final PreferenceGroup prefScreen = getPreferenceScreen(); + final Collection<? extends Preference> prefList = + createInstalledDictSettingsCollection(mClientId); + + activity.runOnUiThread(new Runnable() { + @Override + public void run() { + // TODO: display this somewhere + // if (0 != lastUpdate) mUpdateNowPreference.setSummary(updateNowSummary); + refreshNetworkState(); + + removeAnyDictSettings(prefScreen); + int i = 0; + for (Preference preference : prefList) { + preference.setOrder(i++); + prefScreen.addPreference(preference); + } + } + }); + } + + private static Preference createErrorMessage(final Activity activity, final int messageResource) { + final Preference message = new Preference(activity); + message.setTitle(messageResource); + message.setEnabled(false); + return message; + } + + static void removeAnyDictSettings(final PreferenceGroup prefGroup) { + for (int i = prefGroup.getPreferenceCount() - 1; i >= 0; --i) { + prefGroup.removePreference(prefGroup.getPreference(i)); + } + } + + /** + * Creates a WordListPreference list to be added to the screen. + * + * This method only creates the preferences but does not add them. + * Thus, it can be called on another thread. + * + * @param clientId the id of the client for which we want to display the dictionary list + * @return A collection of preferences ready to add to the interface. + */ + private Collection<? extends Preference> createInstalledDictSettingsCollection( + final String clientId) { + // This will directly contact the DictionaryProvider and request the list exactly like + // any regular client would do. + // Considering the respective value of the respective constants used here for each path, + // segment, the url generated by this is of the form (assuming "clientId" as a clientId) + // content://org.kelar.inputmethod.latin.dictionarypack/clientId/list?procotol=2 + final Uri contentUri = new Uri.Builder().scheme(ContentResolver.SCHEME_CONTENT) + .authority(getString(R.string.authority)) + .appendPath(clientId) + .appendPath(DICT_LIST_ID) + // Need to use version 2 to get this client's list + .appendQueryParameter(DictionaryProvider.QUERY_PARAMETER_PROTOCOL_VERSION, "2") + .build(); + final Activity activity = getActivity(); + final Cursor cursor = (null == activity) ? null + : activity.getContentResolver().query(contentUri, null, null, null, null); + + if (null == cursor) { + final ArrayList<Preference> result = new ArrayList<>(); + result.add(createErrorMessage(activity, R.string.cannot_connect_to_dict_service)); + return result; + } + try { + if (!cursor.moveToFirst()) { + final ArrayList<Preference> result = new ArrayList<>(); + result.add(createErrorMessage(activity, R.string.no_dictionaries_available)); + return result; + } + final String systemLocaleString = Locale.getDefault().toString(); + final TreeMap<String, WordListPreference> prefMap = new TreeMap<>(); + final int idIndex = cursor.getColumnIndex(MetadataDbHelper.WORDLISTID_COLUMN); + final int versionIndex = cursor.getColumnIndex(MetadataDbHelper.VERSION_COLUMN); + final int localeIndex = cursor.getColumnIndex(MetadataDbHelper.LOCALE_COLUMN); + final int descriptionIndex = cursor.getColumnIndex(MetadataDbHelper.DESCRIPTION_COLUMN); + final int statusIndex = cursor.getColumnIndex(MetadataDbHelper.STATUS_COLUMN); + final int filesizeIndex = cursor.getColumnIndex(MetadataDbHelper.FILESIZE_COLUMN); + do { + final String wordlistId = cursor.getString(idIndex); + final int version = cursor.getInt(versionIndex); + final String localeString = cursor.getString(localeIndex); + final Locale locale = new Locale(localeString); + final String description = cursor.getString(descriptionIndex); + final int status = cursor.getInt(statusIndex); + final int matchLevel = LocaleUtils.getMatchLevel(systemLocaleString, localeString); + final String matchLevelString = LocaleUtils.getMatchLevelSortedString(matchLevel); + final int filesize = cursor.getInt(filesizeIndex); + // The key is sorted in lexicographic order, according to the match level, then + // the description. + final String key = matchLevelString + "." + description + "." + wordlistId; + final WordListPreference existingPref = prefMap.get(key); + if (null == existingPref || existingPref.hasPriorityOver(status)) { + final WordListPreference oldPreference = mCurrentPreferenceMap.get(key); + final WordListPreference pref; + if (null != oldPreference + && oldPreference.mVersion == version + && oldPreference.hasStatus(status) + && oldPreference.mLocale.equals(locale)) { + // If the old preference has all the new attributes, reuse it. Ideally, + // we should reuse the old pref even if its status is different and call + // setStatus here, but setStatus calls Preference#setSummary() which + // needs to be done on the UI thread and we're not on the UI thread + // here. We could do all this work on the UI thread, but in this case + // it's probably lighter to stay on a background thread and throw this + // old preference out. + pref = oldPreference; + } else { + // Otherwise, discard it and create a new one instead. + // TODO: when the status is different from the old one, we need to + // animate the old one out before animating the new one in. + pref = new WordListPreference(activity, mDictionaryListInterfaceState, + mClientId, wordlistId, version, locale, description, status, + filesize); + } + prefMap.put(key, pref); + } + } while (cursor.moveToNext()); + mCurrentPreferenceMap = prefMap; + return prefMap.values(); + } finally { + cursor.close(); + } + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + switch (item.getItemId()) { + case MENU_UPDATE_NOW: + if (View.GONE == mLoadingView.getVisibility()) { + startRefresh(); + } else { + cancelRefresh(); + } + return true; + } + return false; + } + + private void startRefresh() { + startLoadingAnimation(); + mChangedSettings = true; + UpdateHandler.registerUpdateEventListener(this); + final Activity activity = getActivity(); + new Thread("updateByHand") { + @Override + public void run() { + // We call tryUpdate(), which returns whether we could successfully start an update. + // If we couldn't, we'll never receive the end callback, so we stop the loading + // animation and return to the previous screen. + if (!UpdateHandler.tryUpdate(activity)) { + stopLoadingAnimation(); + } + } + }.start(); + } + + private void cancelRefresh() { + UpdateHandler.unregisterUpdateEventListener(this); + final Context context = getActivity(); + new Thread("cancelByHand") { + @Override + public void run() { + UpdateHandler.cancelUpdate(context, mClientId); + stopLoadingAnimation(); + } + }.start(); + } + + private void startLoadingAnimation() { + mLoadingView.setVisibility(View.VISIBLE); + getView().setVisibility(View.GONE); + // We come here when the menu element is pressed so presumably it can't be null. But + // better safe than sorry. + if (null != mUpdateNowMenu) mUpdateNowMenu.setTitle(R.string.cancel); + } + + void stopLoadingAnimation() { + final View preferenceView = getView(); + final Activity activity = getActivity(); + if (null == activity) return; + final View loadingView = mLoadingView; + final MenuItem updateNowMenu = mUpdateNowMenu; + activity.runOnUiThread(new Runnable() { + @Override + public void run() { + loadingView.setVisibility(View.GONE); + preferenceView.setVisibility(View.VISIBLE); + loadingView.startAnimation(AnimationUtils.loadAnimation( + activity, android.R.anim.fade_out)); + preferenceView.startAnimation(AnimationUtils.loadAnimation( + activity, android.R.anim.fade_in)); + // The menu is created by the framework asynchronously after the activity, + // which means it's possible to have the activity running but the menu not + // created yet - hence the necessity for a null check here. + if (null != updateNowMenu) { + updateNowMenu.setTitle(R.string.check_for_updates_now); + } + } + }); + } +} diff --git a/java/src/org/kelar/inputmethod/dictionarypack/DownloadIdAndStartDate.java b/java/src/org/kelar/inputmethod/dictionarypack/DownloadIdAndStartDate.java new file mode 100644 index 000000000..cb58abfd5 --- /dev/null +++ b/java/src/org/kelar/inputmethod/dictionarypack/DownloadIdAndStartDate.java @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2014 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; + +/** + * A simple container of download ID and download start date. + */ +public class DownloadIdAndStartDate { + public final long mId; + public final long mStartDate; + public DownloadIdAndStartDate(final long id, final long startDate) { + mId = id; + mStartDate = startDate; + } +} diff --git a/java/src/org/kelar/inputmethod/dictionarypack/DownloadManagerWrapper.java b/java/src/org/kelar/inputmethod/dictionarypack/DownloadManagerWrapper.java new file mode 100644 index 000000000..5881cecf1 --- /dev/null +++ b/java/src/org/kelar/inputmethod/dictionarypack/DownloadManagerWrapper.java @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2014 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.app.DownloadManager; +import android.app.DownloadManager.Query; +import android.app.DownloadManager.Request; +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteException; +import android.os.ParcelFileDescriptor; +import android.util.Log; + +import java.io.FileNotFoundException; +import java.util.Arrays; + +import javax.annotation.Nullable; + +/** + * A class to help with calling DownloadManager methods. + * + * Mostly, the problem here is that most methods from DownloadManager may throw SQL exceptions if + * they can't open the database on disk. We want to avoid crashing in these cases but can't do + * much more, so this class insulates the callers from these. SQLiteException also inherit from + * RuntimeException so they are unchecked :( + * While we're at it, we also insulate callers from the cases where DownloadManager is disabled, + * and getSystemService returns null. + */ +public class DownloadManagerWrapper { + private final static String TAG = DownloadManagerWrapper.class.getSimpleName(); + private final DownloadManager mDownloadManager; + + public DownloadManagerWrapper(final Context context) { + this((DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE)); + } + + private DownloadManagerWrapper(final DownloadManager downloadManager) { + mDownloadManager = downloadManager; + } + + public void remove(final long... ids) { + try { + if (null != mDownloadManager) { + mDownloadManager.remove(ids); + } + } catch (IllegalArgumentException e) { + // This is expected to happen on boot when the device is encrypted. + } catch (SQLiteException e) { + // We couldn't remove the file from DownloadManager. Apparently, the database can't + // be opened. It may be a problem with file system corruption. In any case, there is + // not much we can do apart from avoiding crashing. + Log.e(TAG, "Can't remove files with ID " + Arrays.toString(ids) + + " from download manager", e); + } + } + + public ParcelFileDescriptor openDownloadedFile(final long fileId) throws FileNotFoundException { + try { + if (null != mDownloadManager) { + return mDownloadManager.openDownloadedFile(fileId); + } + } catch (IllegalArgumentException e) { + // This is expected to happen on boot when the device is encrypted. + } catch (SQLiteException e) { + Log.e(TAG, "Can't open downloaded file with ID " + fileId, e); + } + // We come here if mDownloadManager is null or if an exception was thrown. + throw new FileNotFoundException(); + } + + @Nullable + public Cursor query(final Query query) { + try { + if (null != mDownloadManager) { + return mDownloadManager.query(query); + } + } catch (IllegalArgumentException e) { + // This is expected to happen on boot when the device is encrypted. + } catch (SQLiteException e) { + Log.e(TAG, "Can't query the download manager", e); + } + // We come here if mDownloadManager is null or if an exception was thrown. + return null; + } + + public long enqueue(final Request request) { + try { + if (null != mDownloadManager) { + return mDownloadManager.enqueue(request); + } + } catch (IllegalArgumentException e) { + // This is expected to happen on boot when the device is encrypted. + } catch (SQLiteException e) { + Log.e(TAG, "Can't enqueue a request with the download manager", e); + } + return 0; + } +} diff --git a/java/src/org/kelar/inputmethod/dictionarypack/DownloadOverMeteredDialog.java b/java/src/org/kelar/inputmethod/dictionarypack/DownloadOverMeteredDialog.java new file mode 100644 index 000000000..48564535a --- /dev/null +++ b/java/src/org/kelar/inputmethod/dictionarypack/DownloadOverMeteredDialog.java @@ -0,0 +1,86 @@ +/* + * Copyright (C) 2012 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.app.Activity; +import android.content.Intent; +import android.os.Bundle; +import android.text.Html; +import android.view.View; +import android.widget.Button; +import android.widget.TextView; + +import org.kelar.inputmethod.annotations.ExternallyReferenced; +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.common.LocaleUtils; + +import javax.annotation.Nullable; + +/** + * This implements the dialog for asking the user whether it's okay to download dictionaries over + * a metered connection or not (e.g. their mobile data plan). + */ +public final class DownloadOverMeteredDialog extends Activity { + final public static String CLIENT_ID_KEY = "client_id"; + final public static String WORDLIST_TO_DOWNLOAD_KEY = "wordlist_to_download"; + final public static String SIZE_KEY = "size"; + final public static String LOCALE_KEY = "locale"; + private String mClientId; + private String mWordListToDownload; + + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + final Intent intent = getIntent(); + mClientId = intent.getStringExtra(CLIENT_ID_KEY); + mWordListToDownload = intent.getStringExtra(WORDLIST_TO_DOWNLOAD_KEY); + final String localeString = intent.getStringExtra(LOCALE_KEY); + final long size = intent.getIntExtra(SIZE_KEY, 0); + setContentView(R.layout.download_over_metered); + setTexts(localeString, size); + } + + private void setTexts(@Nullable final String localeString, final long size) { + final String promptFormat = getString(R.string.should_download_over_metered_prompt); + final String allowButtonFormat = getString(R.string.download_over_metered); + final String language = (null == localeString) ? "" + : LocaleUtils.constructLocaleFromString(localeString).getDisplayLanguage(); + final TextView prompt = (TextView)findViewById(R.id.download_over_metered_prompt); + prompt.setText(Html.fromHtml(String.format(promptFormat, language))); + final Button allowButton = (Button)findViewById(R.id.allow_button); + allowButton.setText(String.format(allowButtonFormat, ((float)size)/(1024*1024))); + } + + // This method is externally referenced from layout/download_over_metered.xml using onClick + // attribute of Button. + @ExternallyReferenced + @SuppressWarnings("unused") + public void onClickDeny(final View v) { + UpdateHandler.setDownloadOverMeteredSetting(this, false); + finish(); + } + + // This method is externally referenced from layout/download_over_metered.xml using onClick + // attribute of Button. + @ExternallyReferenced + @SuppressWarnings("unused") + public void onClickAllow(final View v) { + UpdateHandler.setDownloadOverMeteredSetting(this, true); + UpdateHandler.installIfNeverRequested(this, mClientId, mWordListToDownload); + finish(); + } +} diff --git a/java/src/org/kelar/inputmethod/dictionarypack/DownloadRecord.java b/java/src/org/kelar/inputmethod/dictionarypack/DownloadRecord.java new file mode 100644 index 000000000..1dddc6042 --- /dev/null +++ b/java/src/org/kelar/inputmethod/dictionarypack/DownloadRecord.java @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2013 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.ContentValues; + +/** + * Struct class to encapsulate a client ID with content values about a download. + */ +public class DownloadRecord { + public final String mClientId; + // Only word lists have attributes, and the ContentValues should contain the same + // keys as they do for all MetadataDbHelper functions. Since only word lists have + // attributes, a null pointer here means this record represents metadata. + public final ContentValues mAttributes; + public DownloadRecord(final String clientId, final ContentValues attributes) { + mClientId = clientId; + mAttributes = attributes; + } + public boolean isMetadata() { + return null == mAttributes; + } +}
\ No newline at end of file diff --git a/java/src/org/kelar/inputmethod/dictionarypack/EventHandler.java b/java/src/org/kelar/inputmethod/dictionarypack/EventHandler.java new file mode 100644 index 000000000..c62597eff --- /dev/null +++ b/java/src/org/kelar/inputmethod/dictionarypack/EventHandler.java @@ -0,0 +1,46 @@ +/* + * 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.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +public final class EventHandler extends BroadcastReceiver { + /** + * Receives a intent broadcast. + * + * We receive every day a broadcast indicating that date changed. + * Then we wait a random amount of time before actually registering + * the download, to avoid concentrating too many accesses around + * midnight in more populated timezones. + * We receive all broadcasts here, so this can be either the DATE_CHANGED broadcast, the + * UPDATE_NOW private broadcast that we receive when the time-randomizing alarm triggers + * for regular update or from applications that want to test the dictionary pack, or a + * broadcast from DownloadManager telling that a download has finished. + * See inside of AndroidManifest.xml to see which events are caught. + * Also @see {@link BroadcastReceiver#onReceive(Context, Intent)} + * + * @param context the context of the application. + * @param intent the intent that was broadcast. + */ + @Override + public void onReceive(final Context context, final Intent intent) { + intent.setClass(context, DictionaryService.class); + context.startService(intent); + } +} diff --git a/java/src/org/kelar/inputmethod/dictionarypack/LogProblemReporter.java b/java/src/org/kelar/inputmethod/dictionarypack/LogProblemReporter.java new file mode 100644 index 000000000..1f1f9dc58 --- /dev/null +++ b/java/src/org/kelar/inputmethod/dictionarypack/LogProblemReporter.java @@ -0,0 +1,35 @@ +/* + * 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.util.Log; + +/** + * A very simple problem reporter. + */ +final class LogProblemReporter implements ProblemReporter { + private final String TAG; + + public LogProblemReporter(final String tag) { + TAG = tag; + } + + @Override + public void report(final Exception e) { + Log.e(TAG, "Reporting problem", e); + } +} diff --git a/java/src/org/kelar/inputmethod/dictionarypack/MD5Calculator.java b/java/src/org/kelar/inputmethod/dictionarypack/MD5Calculator.java new file mode 100644 index 000000000..80d81c090 --- /dev/null +++ b/java/src/org/kelar/inputmethod/dictionarypack/MD5Calculator.java @@ -0,0 +1,46 @@ +/** + * Copyright (C) 2012 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 java.io.InputStream; +import java.io.IOException; +import java.security.MessageDigest; + +public final class MD5Calculator { + private MD5Calculator() {} // This helper class is not instantiable + + public static String checksum(final InputStream in) throws IOException { + // This code from the Android documentation for MessageDigest. Nearly verbatim. + MessageDigest digester; + try { + digester = MessageDigest.getInstance("MD5"); + } catch (java.security.NoSuchAlgorithmException e) { + return null; // Platform does not support MD5 : can't check, so return null + } + final byte[] bytes = new byte[8192]; + int byteCount; + while ((byteCount = in.read(bytes)) > 0) { + digester.update(bytes, 0, byteCount); + } + final byte[] digest = digester.digest(); + final StringBuilder s = new StringBuilder(); + for (int i = 0; i < digest.length; ++i) { + s.append(String.format("%1$02x", digest[i])); + } + return s.toString(); + } +} diff --git a/java/src/org/kelar/inputmethod/dictionarypack/MetadataDbHelper.java b/java/src/org/kelar/inputmethod/dictionarypack/MetadataDbHelper.java new file mode 100644 index 000000000..b8e093997 --- /dev/null +++ b/java/src/org/kelar/inputmethod/dictionarypack/MetadataDbHelper.java @@ -0,0 +1,1155 @@ +/* + * 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.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteException; +import android.database.sqlite.SQLiteOpenHelper; +import android.text.TextUtils; +import android.util.Log; + +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.utils.DebugLogUtils; + +import java.io.File; +import java.util.ArrayList; +import java.util.LinkedList; +import java.util.List; +import java.util.TreeMap; + +import javax.annotation.Nullable; + +/** + * Various helper functions for the state database + */ +public class MetadataDbHelper extends SQLiteOpenHelper { + private static final String TAG = MetadataDbHelper.class.getSimpleName(); + + // This was the initial release version of the database. It should never be + // changed going forward. + private static final int METADATA_DATABASE_INITIAL_VERSION = 3; + // This is the first released version of the database that implements CLIENTID. It is + // used to identify the versions for upgrades. This should never change going forward. + private static final int METADATA_DATABASE_VERSION_WITH_CLIENTID = 6; + // The current database version. + // This MUST be increased every time the dictionary pack metadata URL changes. + private static final int CURRENT_METADATA_DATABASE_VERSION = 16; + + private final static long NOT_A_DOWNLOAD_ID = -1; + + // The number of retries allowed when attempting to download a broken dictionary. + public static final int DICTIONARY_RETRY_THRESHOLD = 2; + + public static final String METADATA_TABLE_NAME = "pendingUpdates"; + static final String CLIENT_TABLE_NAME = "clients"; + public static final String PENDINGID_COLUMN = "pendingid"; // Download Manager ID + public static final String TYPE_COLUMN = "type"; + public static final String STATUS_COLUMN = "status"; + public static final String LOCALE_COLUMN = "locale"; + public static final String WORDLISTID_COLUMN = "id"; + public static final String DESCRIPTION_COLUMN = "description"; + public static final String LOCAL_FILENAME_COLUMN = "filename"; + public static final String REMOTE_FILENAME_COLUMN = "url"; + public static final String DATE_COLUMN = "date"; + public static final String CHECKSUM_COLUMN = "checksum"; + public static final String FILESIZE_COLUMN = "filesize"; + public static final String VERSION_COLUMN = "version"; + public static final String FORMATVERSION_COLUMN = "formatversion"; + public static final String FLAGS_COLUMN = "flags"; + public static final String RAW_CHECKSUM_COLUMN = "rawChecksum"; + public static final String RETRY_COUNT_COLUMN = "remainingRetries"; + public static final int COLUMN_COUNT = 15; + + private static final String CLIENT_CLIENT_ID_COLUMN = "clientid"; + private static final String CLIENT_METADATA_URI_COLUMN = "uri"; + private static final String CLIENT_METADATA_ADDITIONAL_ID_COLUMN = "additionalid"; + private static final String CLIENT_LAST_UPDATE_DATE_COLUMN = "lastupdate"; + private static final String CLIENT_PENDINGID_COLUMN = "pendingid"; // Download Manager ID + + public static final String METADATA_DATABASE_NAME_STEM = "pendingUpdates"; + public static final String METADATA_UPDATE_DESCRIPTION = "metadata"; + + public static final String DICTIONARIES_ASSETS_PATH = "dictionaries"; + + // Statuses, for storing in the STATUS_COLUMN + // IMPORTANT: The following are used as index arrays in ../WordListPreference + // Do not change their values without updating the matched code. + // Unknown status: this should never happen. + public static final int STATUS_UNKNOWN = 0; + // Available: this word list is available, but it is not downloaded (not downloading), because + // it is set not to be used. + public static final int STATUS_AVAILABLE = 1; + // Downloading: this word list is being downloaded. + public static final int STATUS_DOWNLOADING = 2; + // Installed: this word list is installed and usable. + public static final int STATUS_INSTALLED = 3; + // Disabled: this word list is installed, but has been disabled by the user. + public static final int STATUS_DISABLED = 4; + // Deleting: the user marked this word list to be deleted, but it has not been yet because + // Latin IME is not up yet. + public static final int STATUS_DELETING = 5; + // Retry: dictionary got corrupted, so an attempt must be done to download & install it again. + public static final int STATUS_RETRYING = 6; + + // Types, for storing in the TYPE_COLUMN + // This is metadata about what is available. + public static final int TYPE_METADATA = 1; + // This is a bulk file. It should replace older files. + public static final int TYPE_BULK = 2; + // This is an incremental update, expected to be small, and meaningless on its own. + public static final int TYPE_UPDATE = 3; + + private static final String METADATA_TABLE_CREATE = + "CREATE TABLE " + METADATA_TABLE_NAME + " (" + + PENDINGID_COLUMN + " INTEGER, " + + TYPE_COLUMN + " INTEGER, " + + STATUS_COLUMN + " INTEGER, " + + WORDLISTID_COLUMN + " TEXT, " + + LOCALE_COLUMN + " TEXT, " + + DESCRIPTION_COLUMN + " TEXT, " + + LOCAL_FILENAME_COLUMN + " TEXT, " + + REMOTE_FILENAME_COLUMN + " TEXT, " + + DATE_COLUMN + " INTEGER, " + + CHECKSUM_COLUMN + " TEXT, " + + FILESIZE_COLUMN + " INTEGER, " + + VERSION_COLUMN + " INTEGER," + + FORMATVERSION_COLUMN + " INTEGER, " + + FLAGS_COLUMN + " INTEGER, " + + RAW_CHECKSUM_COLUMN + " TEXT," + + RETRY_COUNT_COLUMN + " INTEGER, " + + "PRIMARY KEY (" + WORDLISTID_COLUMN + "," + VERSION_COLUMN + "));"; + private static final String METADATA_CREATE_CLIENT_TABLE = + "CREATE TABLE IF NOT EXISTS " + CLIENT_TABLE_NAME + " (" + + CLIENT_CLIENT_ID_COLUMN + " TEXT, " + + CLIENT_METADATA_URI_COLUMN + " TEXT, " + + CLIENT_METADATA_ADDITIONAL_ID_COLUMN + " TEXT, " + + CLIENT_LAST_UPDATE_DATE_COLUMN + " INTEGER NOT NULL DEFAULT 0, " + + CLIENT_PENDINGID_COLUMN + " INTEGER, " + + FLAGS_COLUMN + " INTEGER, " + + "PRIMARY KEY (" + CLIENT_CLIENT_ID_COLUMN + "));"; + + // List of all metadata table columns. + static final String[] METADATA_TABLE_COLUMNS = { PENDINGID_COLUMN, TYPE_COLUMN, + STATUS_COLUMN, WORDLISTID_COLUMN, LOCALE_COLUMN, DESCRIPTION_COLUMN, + LOCAL_FILENAME_COLUMN, REMOTE_FILENAME_COLUMN, DATE_COLUMN, CHECKSUM_COLUMN, + FILESIZE_COLUMN, VERSION_COLUMN, FORMATVERSION_COLUMN, FLAGS_COLUMN, + RAW_CHECKSUM_COLUMN, RETRY_COUNT_COLUMN }; + // List of all client table columns. + static final String[] CLIENT_TABLE_COLUMNS = { CLIENT_CLIENT_ID_COLUMN, + CLIENT_METADATA_URI_COLUMN, CLIENT_PENDINGID_COLUMN, FLAGS_COLUMN }; + // List of public columns returned to clients. Everything that is not in this list is + // private and implementation-dependent. + static final String[] DICTIONARIES_LIST_PUBLIC_COLUMNS = { STATUS_COLUMN, WORDLISTID_COLUMN, + LOCALE_COLUMN, DESCRIPTION_COLUMN, DATE_COLUMN, FILESIZE_COLUMN, VERSION_COLUMN }; + + // This class exhibits a singleton-like behavior by client ID, so it is getInstance'd + // and has a private c'tor. + private static TreeMap<String, MetadataDbHelper> sInstanceMap = null; + public static synchronized MetadataDbHelper getInstance(final Context context, + final String clientIdOrNull) { + // As a backward compatibility feature, null can be passed here to retrieve the "default" + // database. Before multi-client support, the dictionary packed used only one database + // and would not be able to handle several dictionary sets. Passing null here retrieves + // this legacy database. New clients should make sure to always pass a client ID so as + // to avoid conflicts. + final String clientId = null != clientIdOrNull ? clientIdOrNull : ""; + if (null == sInstanceMap) sInstanceMap = new TreeMap<>(); + MetadataDbHelper helper = sInstanceMap.get(clientId); + if (null == helper) { + helper = new MetadataDbHelper(context, clientId); + sInstanceMap.put(clientId, helper); + } + return helper; + } + private MetadataDbHelper(final Context context, final String clientId) { + super(context, + METADATA_DATABASE_NAME_STEM + (TextUtils.isEmpty(clientId) ? "" : "." + clientId), + null, CURRENT_METADATA_DATABASE_VERSION); + mContext = context; + mClientId = clientId; + } + + private final Context mContext; + private final String mClientId; + + /** + * Get the database itself. This always returns the same object for any client ID. If the + * client ID is null, a default database is returned for backward compatibility. Don't + * pass null for new calls. + * + * @param context the context to create the database from. This is ignored after the first call. + * @param clientId the client id to retrieve the database of. null for default (deprecated) + * @return the database. + */ + public static SQLiteDatabase getDb(final Context context, final String clientId) { + return getInstance(context, clientId).getWritableDatabase(); + } + + private void createClientTable(final SQLiteDatabase db) { + // The clients table only exists in the primary db, the one that has an empty client id + if (!TextUtils.isEmpty(mClientId)) return; + db.execSQL(METADATA_CREATE_CLIENT_TABLE); + final String defaultMetadataUri = mContext.getString(R.string.default_metadata_uri); + if (!TextUtils.isEmpty(defaultMetadataUri)) { + final ContentValues defaultMetadataValues = new ContentValues(); + defaultMetadataValues.put(CLIENT_CLIENT_ID_COLUMN, ""); + defaultMetadataValues.put(CLIENT_METADATA_URI_COLUMN, defaultMetadataUri); + defaultMetadataValues.put(CLIENT_PENDINGID_COLUMN, UpdateHandler.NOT_AN_ID); + db.insert(CLIENT_TABLE_NAME, null, defaultMetadataValues); + } + } + + /** + * Create the table and populate it with the resources found inside the apk. + * + * @see SQLiteOpenHelper#onCreate(SQLiteDatabase) + * + * @param db the database to create and populate. + */ + @Override + public void onCreate(final SQLiteDatabase db) { + db.execSQL(METADATA_TABLE_CREATE); + createClientTable(db); + } + + private static void addRawChecksumColumnUnlessPresent(final SQLiteDatabase db) { + try { + db.execSQL("SELECT " + RAW_CHECKSUM_COLUMN + " FROM " + + METADATA_TABLE_NAME + " LIMIT 0;"); + } catch (SQLiteException e) { + Log.i(TAG, "No " + RAW_CHECKSUM_COLUMN + " column : creating it"); + db.execSQL("ALTER TABLE " + METADATA_TABLE_NAME + " ADD COLUMN " + + RAW_CHECKSUM_COLUMN + " TEXT;"); + } + } + + private static void addRetryCountColumnUnlessPresent(final SQLiteDatabase db) { + try { + db.execSQL("SELECT " + RETRY_COUNT_COLUMN + " FROM " + + METADATA_TABLE_NAME + " LIMIT 0;"); + } catch (SQLiteException e) { + Log.i(TAG, "No " + RETRY_COUNT_COLUMN + " column : creating it"); + db.execSQL("ALTER TABLE " + METADATA_TABLE_NAME + " ADD COLUMN " + + RETRY_COUNT_COLUMN + " INTEGER DEFAULT " + DICTIONARY_RETRY_THRESHOLD + ";"); + } + } + + /** + * Upgrade the database. Upgrade from version 3 is supported. + * Version 3 has a DB named METADATA_DATABASE_NAME_STEM containing a table METADATA_TABLE_NAME. + * Version 6 and above has a DB named METADATA_DATABASE_NAME_STEM containing a + * table CLIENT_TABLE_NAME, and for each client a table called METADATA_TABLE_STEM + "." + the + * name of the client and contains a table METADATA_TABLE_NAME. + * For schemas, see the above create statements. The schemas have never changed so far. + * + * This method is called by the framework. See {@link SQLiteOpenHelper#onUpgrade} + * @param db The database we are upgrading + * @param oldVersion The old database version (the one on the disk) + * @param newVersion The new database version as supplied to the constructor of SQLiteOpenHelper + */ + @Override + public void onUpgrade(final SQLiteDatabase db, final int oldVersion, final int newVersion) { + if (METADATA_DATABASE_INITIAL_VERSION == oldVersion + && METADATA_DATABASE_VERSION_WITH_CLIENTID <= newVersion + && CURRENT_METADATA_DATABASE_VERSION >= newVersion) { + // Upgrade from version METADATA_DATABASE_INITIAL_VERSION to version + // METADATA_DATABASE_VERSION_WITH_CLIENT_ID + // Only the default database should contain the client table, so we test for mClientId. + if (TextUtils.isEmpty(mClientId)) { + // Anyway in version 3 only the default table existed so the emptiness + // test should always be true, but better check to be sure. + createClientTable(db); + } + } else if (METADATA_DATABASE_VERSION_WITH_CLIENTID < newVersion + && CURRENT_METADATA_DATABASE_VERSION >= newVersion) { + // Here we drop the client table, so that all clients send us their information again. + // The client table contains the URL to hit to update the available dictionaries list, + // but the info about the dictionaries themselves is stored in the table called + // METADATA_TABLE_NAME and we want to keep it, so we only drop the client table. + db.execSQL("DROP TABLE IF EXISTS " + CLIENT_TABLE_NAME); + // Only the default database should contain the client table, so we test for mClientId. + if (TextUtils.isEmpty(mClientId)) { + createClientTable(db); + } + } else { + // If we're not in the above case, either we are upgrading from an earlier versionCode + // and we should wipe the database, or we are handling a version we never heard about + // (can only be a bug) so it's safer to wipe the database. + db.execSQL("DROP TABLE IF EXISTS " + METADATA_TABLE_NAME); + db.execSQL("DROP TABLE IF EXISTS " + CLIENT_TABLE_NAME); + onCreate(db); + } + // A rawChecksum column that did not exist in the previous versions was added that + // corresponds to the md5 checksum of the file after decompression/decryption. This is to + // strengthen the system against corrupted dictionary files. + // The most secure way to upgrade a database is to just test for the column presence, and + // add it if it's not there. + addRawChecksumColumnUnlessPresent(db); + + // A retry count column that did not exist in the previous versions was added that + // corresponds to the number of download & installation attempts that have been made + // in order to strengthen the system recovery from corrupted dictionary files. + // The most secure way to upgrade a database is to just test for the column presence, and + // add it if it's not there. + addRetryCountColumnUnlessPresent(db); + } + + /** + * Downgrade the database. This drops and recreates the table in all cases. + */ + @Override + public void onDowngrade(final SQLiteDatabase db, final int oldVersion, final int newVersion) { + // No matter what the numerical values of oldVersion and newVersion are, we know this + // is a downgrade (newVersion < oldVersion). There is no way to know what the future + // databases will look like, but we know it's extremely likely that it's okay to just + // drop the tables and start from scratch. Hence, we ignore the versions and just wipe + // everything we want to use. + if (oldVersion <= newVersion) { + Log.e(TAG, "onDowngrade database but new version is higher? " + oldVersion + " <= " + + newVersion); + } + db.execSQL("DROP TABLE IF EXISTS " + METADATA_TABLE_NAME); + db.execSQL("DROP TABLE IF EXISTS " + CLIENT_TABLE_NAME); + onCreate(db); + } + + /** + * Given a client ID, returns whether this client exists. + * + * @param context a context to open the database + * @param clientId the client ID to check + * @return true if the client is known, false otherwise + */ + public static boolean isClientKnown(final Context context, final String clientId) { + // If the client is known, they'll have a non-null metadata URI. An empty string is + // allowed as a metadata URI, if the client doesn't want any updates to happen. + return null != getMetadataUriAsString(context, clientId); + } + + private static final MetadataUriGetter sMetadataUriGetter = new MetadataUriGetter(); + + /** + * Returns the metadata URI as a string. + * + * If the client is not known, this will return null. If it is known, it will return + * the URI as a string. Note that the empty string is a valid value. + * + * @param context a context instance to open the database on + * @param clientId the ID of the client we want the metadata URI of + * @return the string representation of the URI + */ + public static String getMetadataUriAsString(final Context context, final String clientId) { + SQLiteDatabase defaultDb = MetadataDbHelper.getDb(context, null); + final Cursor cursor = defaultDb.query(MetadataDbHelper.CLIENT_TABLE_NAME, + new String[] { MetadataDbHelper.CLIENT_METADATA_URI_COLUMN }, + MetadataDbHelper.CLIENT_CLIENT_ID_COLUMN + " = ?", new String[] { clientId }, + null, null, null, null); + try { + if (!cursor.moveToFirst()) return null; + return sMetadataUriGetter.getUri(context, cursor.getString(0)); + } finally { + cursor.close(); + } + } + + /** + * Update the last metadata update time for all clients using a particular URI. + * + * This method searches for all clients using a particular URI and updates the last + * update time for this client. + * The current time is used as the latest update time. This saved date will be what + * is returned henceforth by {@link #getLastUpdateDateForClient(Context, String)}, + * until this method is called again. + * + * @param context a context instance to open the database on + * @param uri the metadata URI we just downloaded + */ + public static void saveLastUpdateTimeOfUri(final Context context, final String uri) { + PrivateLog.log("Save last update time of URI : " + uri + " " + System.currentTimeMillis()); + final ContentValues values = new ContentValues(); + values.put(CLIENT_LAST_UPDATE_DATE_COLUMN, System.currentTimeMillis()); + final SQLiteDatabase defaultDb = getDb(context, null); + final Cursor cursor = MetadataDbHelper.queryClientIds(context); + if (null == cursor) return; + try { + if (!cursor.moveToFirst()) return; + do { + final String clientId = cursor.getString(0); + final String metadataUri = + MetadataDbHelper.getMetadataUriAsString(context, clientId); + if (metadataUri.equals(uri)) { + defaultDb.update(CLIENT_TABLE_NAME, values, + CLIENT_CLIENT_ID_COLUMN + " = ?", new String[] { clientId }); + } + } while (cursor.moveToNext()); + } finally { + cursor.close(); + } + } + + /** + * Retrieves the last date at which we updated the metadata for this client. + * + * The returned date is in milliseconds from the EPOCH; this is the same unit as + * returned by {@link System#currentTimeMillis()}. + * + * @param context a context instance to open the database on + * @param clientId the client ID to get the latest update date of + * @return the last date at which this client was updated, as a long. + */ + public static long getLastUpdateDateForClient(final Context context, final String clientId) { + SQLiteDatabase defaultDb = getDb(context, null); + final Cursor cursor = defaultDb.query(CLIENT_TABLE_NAME, + new String[] { CLIENT_LAST_UPDATE_DATE_COLUMN }, + CLIENT_CLIENT_ID_COLUMN + " = ?", + new String[] { null == clientId ? "" : clientId }, + null, null, null, null); + try { + if (!cursor.moveToFirst()) return 0; + return cursor.getLong(0); // Only one column, return it + } finally { + cursor.close(); + } + } + + /** + * Get the metadata download ID for a metadata URI. + * + * This will retrieve the download ID for the metadata file that has the passed URI. + * If this URI is not being downloaded right now, it will return NOT_AN_ID. + * + * @param context a context instance to open the database on + * @param uri the URI to retrieve the metadata download ID of + * @return the download id and start date, or null if the URL is not known + */ + public static DownloadIdAndStartDate getMetadataDownloadIdAndStartDateForURI( + final Context context, final String uri) { + SQLiteDatabase defaultDb = getDb(context, null); + final Cursor cursor = defaultDb.query(CLIENT_TABLE_NAME, + new String[] { CLIENT_PENDINGID_COLUMN, CLIENT_LAST_UPDATE_DATE_COLUMN }, + CLIENT_METADATA_URI_COLUMN + " = ?", new String[] { uri }, + null, null, null, null); + try { + if (!cursor.moveToFirst()) return null; + return new DownloadIdAndStartDate(cursor.getInt(0), cursor.getLong(1)); + } finally { + cursor.close(); + } + } + + public static long getOldestUpdateTime(final Context context) { + SQLiteDatabase defaultDb = getDb(context, null); + final Cursor cursor = defaultDb.query(CLIENT_TABLE_NAME, + new String[] { CLIENT_LAST_UPDATE_DATE_COLUMN }, + null, null, null, null, null); + try { + if (!cursor.moveToFirst()) return 0; + final int columnIndex = 0; // Only one column queried + // Initialize the earliestTime to the largest possible value. + long earliestTime = Long.MAX_VALUE; // Almost 300 million years in the future + do { + final long thisTime = cursor.getLong(columnIndex); + earliestTime = Math.min(thisTime, earliestTime); + } while (cursor.moveToNext()); + return earliestTime; + } finally { + cursor.close(); + } + } + + /** + * Helper method to make content values to write into the database. + * @return content values with all the arguments put with the right column names. + */ + public static ContentValues makeContentValues(final int pendingId, final int type, + final int status, final String wordlistId, final String locale, + final String description, final String filename, final String url, final long date, + final String rawChecksum, final String checksum, final int retryCount, + final long filesize, final int version, final int formatVersion) { + final ContentValues result = new ContentValues(COLUMN_COUNT); + result.put(PENDINGID_COLUMN, pendingId); + result.put(TYPE_COLUMN, type); + result.put(WORDLISTID_COLUMN, wordlistId); + result.put(STATUS_COLUMN, status); + result.put(LOCALE_COLUMN, locale); + result.put(DESCRIPTION_COLUMN, description); + result.put(LOCAL_FILENAME_COLUMN, filename); + result.put(REMOTE_FILENAME_COLUMN, url); + result.put(DATE_COLUMN, date); + result.put(RAW_CHECKSUM_COLUMN, rawChecksum); + result.put(RETRY_COUNT_COLUMN, retryCount); + result.put(CHECKSUM_COLUMN, checksum); + result.put(FILESIZE_COLUMN, filesize); + result.put(VERSION_COLUMN, version); + result.put(FORMATVERSION_COLUMN, formatVersion); + result.put(FLAGS_COLUMN, 0); + return result; + } + + /** + * Helper method to fill in an incomplete ContentValues with default values. + * A wordlist ID and a locale are required, otherwise BadFormatException is thrown. + * @return the same object that was passed in, completed with default values. + */ + public static ContentValues completeWithDefaultValues(final ContentValues result) + throws BadFormatException { + if (null == result.get(WORDLISTID_COLUMN) || null == result.get(LOCALE_COLUMN)) { + throw new BadFormatException(); + } + // 0 for the pending id, because there is none + if (null == result.get(PENDINGID_COLUMN)) result.put(PENDINGID_COLUMN, 0); + // This is a binary blob of a dictionary + if (null == result.get(TYPE_COLUMN)) result.put(TYPE_COLUMN, TYPE_BULK); + // This word list is unknown, but it's present, else we wouldn't be here, so INSTALLED + if (null == result.get(STATUS_COLUMN)) result.put(STATUS_COLUMN, STATUS_INSTALLED); + // No description unless specified, because we can't guess it + if (null == result.get(DESCRIPTION_COLUMN)) result.put(DESCRIPTION_COLUMN, ""); + // File name - this is an asset, so it works as an already deleted file. + // hence, we need to supply a non-existent file name. Anything will + // do as long as it returns false when tested with File#exist(), and + // the empty string does not, so it's set to "_". + if (null == result.get(LOCAL_FILENAME_COLUMN)) result.put(LOCAL_FILENAME_COLUMN, "_"); + // No remote file name : this can't be downloaded. Unless specified. + if (null == result.get(REMOTE_FILENAME_COLUMN)) result.put(REMOTE_FILENAME_COLUMN, ""); + // 0 for the update date : 1970/1/1. Unless specified. + if (null == result.get(DATE_COLUMN)) result.put(DATE_COLUMN, 0); + // Raw checksum unknown unless specified + if (null == result.get(RAW_CHECKSUM_COLUMN)) result.put(RAW_CHECKSUM_COLUMN, ""); + // Retry column 0 unless specified + if (null == result.get(RETRY_COUNT_COLUMN)) result.put(RETRY_COUNT_COLUMN, + DICTIONARY_RETRY_THRESHOLD); + // Checksum unknown unless specified + if (null == result.get(CHECKSUM_COLUMN)) result.put(CHECKSUM_COLUMN, ""); + // No filesize unless specified + if (null == result.get(FILESIZE_COLUMN)) result.put(FILESIZE_COLUMN, 0); + // Smallest possible version unless specified + if (null == result.get(VERSION_COLUMN)) result.put(VERSION_COLUMN, 1); + // Assume current format unless specified + if (null == result.get(FORMATVERSION_COLUMN)) + result.put(FORMATVERSION_COLUMN, UpdateHandler.MAXIMUM_SUPPORTED_FORMAT_VERSION); + // No flags unless specified + if (null == result.get(FLAGS_COLUMN)) result.put(FLAGS_COLUMN, 0); + return result; + } + + /** + * Reads a column in a Cursor as a String and stores it in a ContentValues object. + * @param result the ContentValues object to store the result in. + * @param cursor the Cursor to read the column from. + * @param columnId the column ID to read. + */ + private static void putStringResult(ContentValues result, Cursor cursor, String columnId) { + result.put(columnId, cursor.getString(cursor.getColumnIndex(columnId))); + } + + /** + * Reads a column in a Cursor as an int and stores it in a ContentValues object. + * @param result the ContentValues object to store the result in. + * @param cursor the Cursor to read the column from. + * @param columnId the column ID to read. + */ + private static void putIntResult(ContentValues result, Cursor cursor, String columnId) { + result.put(columnId, cursor.getInt(cursor.getColumnIndex(columnId))); + } + + private static ContentValues getFirstLineAsContentValues(final Cursor cursor) { + final ContentValues result; + if (cursor.moveToFirst()) { + result = new ContentValues(COLUMN_COUNT); + putIntResult(result, cursor, PENDINGID_COLUMN); + putIntResult(result, cursor, TYPE_COLUMN); + putIntResult(result, cursor, STATUS_COLUMN); + putStringResult(result, cursor, WORDLISTID_COLUMN); + putStringResult(result, cursor, LOCALE_COLUMN); + putStringResult(result, cursor, DESCRIPTION_COLUMN); + putStringResult(result, cursor, LOCAL_FILENAME_COLUMN); + putStringResult(result, cursor, REMOTE_FILENAME_COLUMN); + putIntResult(result, cursor, DATE_COLUMN); + putStringResult(result, cursor, RAW_CHECKSUM_COLUMN); + putStringResult(result, cursor, CHECKSUM_COLUMN); + putIntResult(result, cursor, RETRY_COUNT_COLUMN); + putIntResult(result, cursor, FILESIZE_COLUMN); + putIntResult(result, cursor, VERSION_COLUMN); + putIntResult(result, cursor, FORMATVERSION_COLUMN); + putIntResult(result, cursor, FLAGS_COLUMN); + if (cursor.moveToNext()) { + // TODO: print the second level of the stack to the log so that we know + // in which code path the error happened + Log.e(TAG, "Several SQL results when we expected only one!"); + } + } else { + result = null; + } + return result; + } + + /** + * Gets the info about as specific download, indexed by its DownloadManager ID. + * @param db the database to get the information from. + * @param id the DownloadManager id. + * @return metadata about this download. This returns all columns in the database. + */ + public static ContentValues getContentValuesByPendingId(final SQLiteDatabase db, + final long id) { + final Cursor cursor = db.query(METADATA_TABLE_NAME, + METADATA_TABLE_COLUMNS, + PENDINGID_COLUMN + "= ?", + new String[] { Long.toString(id) }, + null, null, null); + if (null == cursor) { + return null; + } + try { + // There should never be more than one result. If because of some bug there are, + // returning only one result is the right thing to do, because we couldn't handle + // several anyway and we should still handle one. + return getFirstLineAsContentValues(cursor); + } finally { + cursor.close(); + } + } + + /** + * Gets the info about an installed OR deleting word list with a specified id. + * + * Basically, this is the word list that we want to return to Kelar Keyboard when + * it asks for a specific id. + * + * @param db the database to get the information from. + * @param id the word list ID. + * @return the metadata about this word list. + */ + public static ContentValues getInstalledOrDeletingWordListContentValuesByWordListId( + final SQLiteDatabase db, final String id) { + final Cursor cursor = db.query(METADATA_TABLE_NAME, + METADATA_TABLE_COLUMNS, + WORDLISTID_COLUMN + "=? AND (" + STATUS_COLUMN + "=? OR " + STATUS_COLUMN + "=?)", + new String[] { id, Integer.toString(STATUS_INSTALLED), + Integer.toString(STATUS_DELETING) }, + null, null, null); + if (null == cursor) { + return null; + } + try { + // There should only be one result, but if there are several, we can't tell which + // is the best, so we just return the first one. + return getFirstLineAsContentValues(cursor); + } finally { + cursor.close(); + } + } + + /** + * Given a specific download ID, return records for all pending downloads across all clients. + * + * If several clients use the same metadata URL, we know to only download it once, and + * dispatch the update process across all relevant clients when the download ends. This means + * several clients may share a single download ID if they share a metadata URI. + * The dispatching is done in + * {@link UpdateHandler#downloadFinished(Context, android.content.Intent)}, which + * finds out about the list of relevant clients by calling this method. + * + * @param context a context instance to open the databases + * @param downloadId the download ID to query about + * @return the list of records. Never null, but may be empty. + */ + public static ArrayList<DownloadRecord> getDownloadRecordsForDownloadId(final Context context, + final long downloadId) { + final SQLiteDatabase defaultDb = getDb(context, ""); + final ArrayList<DownloadRecord> results = new ArrayList<>(); + final Cursor cursor = defaultDb.query(CLIENT_TABLE_NAME, CLIENT_TABLE_COLUMNS, + null, null, null, null, null); + try { + if (!cursor.moveToFirst()) return results; + final int clientIdIndex = cursor.getColumnIndex(CLIENT_CLIENT_ID_COLUMN); + final int pendingIdColumn = cursor.getColumnIndex(CLIENT_PENDINGID_COLUMN); + do { + final long pendingId = cursor.getInt(pendingIdColumn); + final String clientId = cursor.getString(clientIdIndex); + if (pendingId == downloadId) { + results.add(new DownloadRecord(clientId, null)); + } + final ContentValues valuesForThisClient = + getContentValuesByPendingId(getDb(context, clientId), downloadId); + if (null != valuesForThisClient) { + results.add(new DownloadRecord(clientId, valuesForThisClient)); + } + } while (cursor.moveToNext()); + } finally { + cursor.close(); + } + return results; + } + + /** + * Gets the info about a specific word list. + * + * @param db the database to get the information from. + * @param id the word list ID. + * @param version the word list version. + * @return the metadata about this word list. + */ + @Nullable + public static ContentValues getContentValuesByWordListId(final SQLiteDatabase db, + final String id, final int version) { + final Cursor cursor = db.query(METADATA_TABLE_NAME, + METADATA_TABLE_COLUMNS, + WORDLISTID_COLUMN + "= ? AND " + VERSION_COLUMN + "= ? AND " + + FORMATVERSION_COLUMN + "<= ?", + new String[] + { id, + Integer.toString(version), + Integer.toString(UpdateHandler.MAXIMUM_SUPPORTED_FORMAT_VERSION) + }, + null /* groupBy */, + null /* having */, + FORMATVERSION_COLUMN + " DESC"/* orderBy */); + if (null == cursor) { + return null; + } + try { + // This is a lookup by primary key, so there can't be more than one result. + return getFirstLineAsContentValues(cursor); + } finally { + cursor.close(); + } + } + + /** + * Gets the info about the latest word list with an id. + * + * @param db the database to get the information from. + * @param id the word list ID. + * @return the metadata about the word list with this id and the latest version number. + */ + public static ContentValues getContentValuesOfLatestAvailableWordlistById( + final SQLiteDatabase db, final String id) { + final Cursor cursor = db.query(METADATA_TABLE_NAME, + METADATA_TABLE_COLUMNS, + WORDLISTID_COLUMN + "= ?", + new String[] { id }, null, null, VERSION_COLUMN + " DESC", "1"); + if (null == cursor) { + return null; + } + try { + // Return the first result from the list of results. + return getFirstLineAsContentValues(cursor); + } finally { + cursor.close(); + } + } + + /** + * Gets the current metadata about INSTALLED, AVAILABLE or DELETING dictionaries. + * + * This odd method is tailored to the needs of + * DictionaryProvider#getDictionaryWordListsForContentUri, which needs the word list if + * it is: + * - INSTALLED: this should be returned to LatinIME if the file is still inside the dictionary + * pack, so that it can be copied. If the file is not there, it's been copied already and should + * not be returned, so getDictionaryWordListsForContentUri takes care of this. + * - DELETING: this should be returned to LatinIME so that it can actually delete the file. + * - AVAILABLE: this should not be returned, but should be checked for auto-installation. + * + * @param context the context for getting the database. + * @param clientId the client id for retrieving the database. null for default (deprecated) + * @return a cursor with metadata about usable dictionaries. + */ + public static Cursor queryInstalledOrDeletingOrAvailableDictionaryMetadata( + final Context context, final String clientId) { + // If clientId is null, we get the defaut DB (see #getInstance() for more about this) + final Cursor results = getDb(context, clientId).query(METADATA_TABLE_NAME, + METADATA_TABLE_COLUMNS, + STATUS_COLUMN + " = ? OR " + STATUS_COLUMN + " = ? OR " + STATUS_COLUMN + " = ?", + new String[] { Integer.toString(STATUS_INSTALLED), + Integer.toString(STATUS_DELETING), + Integer.toString(STATUS_AVAILABLE) }, + null, null, LOCALE_COLUMN); + return results; + } + + /** + * Gets the current metadata about all dictionaries. + * + * This will retrieve the metadata about all dictionaries, including + * older files, or files not yet downloaded. + * + * @param context the context for getting the database. + * @param clientId the client id for retrieving the database. null for default (deprecated) + * @return a cursor with metadata about usable dictionaries. + */ + public static Cursor queryCurrentMetadata(final Context context, final String clientId) { + // If clientId is null, we get the defaut DB (see #getInstance() for more about this) + final Cursor results = getDb(context, clientId).query(METADATA_TABLE_NAME, + METADATA_TABLE_COLUMNS, null, null, null, null, LOCALE_COLUMN); + return results; + } + + /** + * Gets the list of all dictionaries known to the dictionary provider, with only public columns. + * + * This will retrieve information about all known dictionaries, and their status. As such, + * it will also return information about dictionaries on the server that have not been + * downloaded yet, but may be requested. + * This only returns public columns. It does not populate internal columns in the returned + * cursor. + * The value returned by this method is intended to be good to be returned directly for a + * request of the list of dictionaries by a client. + * + * @param context the context to read the database from. + * @param clientId the client id for retrieving the database. null for default (deprecated) + * @return a cursor that lists all available dictionaries and their metadata. + */ + public static Cursor queryDictionaries(final Context context, final String clientId) { + // If clientId is null, we get the defaut DB (see #getInstance() for more about this) + final Cursor results = getDb(context, clientId).query(METADATA_TABLE_NAME, + DICTIONARIES_LIST_PUBLIC_COLUMNS, + // Filter out empty locales so as not to return auxiliary data, like a + // data line for downloading metadata: + MetadataDbHelper.LOCALE_COLUMN + " != ?", new String[] {""}, + // TODO: Reinstate the following code for bulk, then implement partial updates + /* MetadataDbHelper.TYPE_COLUMN + " = ?", + new String[] { Integer.toString(MetadataDbHelper.TYPE_BULK) }, */ + null, null, LOCALE_COLUMN); + return results; + } + + /** + * Deletes all data associated with a client. + * + * @param context the context for opening the database + * @param clientId the ID of the client to delete. + * @return true if the client was successfully deleted, false otherwise. + */ + public static boolean deleteClient(final Context context, final String clientId) { + // Remove all metadata associated with this client + final SQLiteDatabase db = getDb(context, clientId); + db.execSQL("DROP TABLE IF EXISTS " + METADATA_TABLE_NAME); + db.execSQL(METADATA_TABLE_CREATE); + // Remove this client's entry in the clients table + final SQLiteDatabase defaultDb = getDb(context, ""); + if (0 == defaultDb.delete(CLIENT_TABLE_NAME, + CLIENT_CLIENT_ID_COLUMN + " = ?", new String[] { clientId })) { + return false; + } + return true; + } + + /** + * Updates information relative to a specific client. + * + * Updatable information includes the metadata URI and the additional ID column. It may be + * expanded in the future. + * The passed values must include a client ID in the key CLIENT_CLIENT_ID_COLUMN, and it must + * be equal to the string passed as an argument for clientId. It may not be empty. + * The passed values must also include a non-null metadata URI in the + * CLIENT_METADATA_URI_COLUMN column, as well as a non-null additional ID in the + * CLIENT_METADATA_ADDITIONAL_ID_COLUMN. Both these strings may be empty. + * If any of the above is not complied with, this function returns without updating data. + * + * @param context the context, to open the database + * @param clientId the ID of the client to update + * @param values the values to update. Must conform to the protocol (see above) + */ + public static void updateClientInfo(final Context context, final String clientId, + final ContentValues values) { + // Validity check the content values + final String valuesClientId = values.getAsString(CLIENT_CLIENT_ID_COLUMN); + final String valuesMetadataUri = values.getAsString(CLIENT_METADATA_URI_COLUMN); + final String valuesMetadataAdditionalId = + values.getAsString(CLIENT_METADATA_ADDITIONAL_ID_COLUMN); + // Empty string is a valid client ID, but external apps may not configure it, so disallow + // both null and empty string. + // Empty string is a valid metadata URI if the client does not want updates, so allow + // empty string but disallow null. + // Empty string is a valid additional ID so allow empty string but disallow null. + if (TextUtils.isEmpty(valuesClientId) || null == valuesMetadataUri + || null == valuesMetadataAdditionalId) { + // We need all these columns to be filled in + DebugLogUtils.l("Missing parameter for updateClientInfo"); + return; + } + if (!clientId.equals(valuesClientId)) { + // Mismatch! The client violates the protocol. + DebugLogUtils.l("Received an updateClientInfo request for ", clientId, + " but the values " + "contain a different ID : ", valuesClientId); + return; + } + // Default value for a pending ID is NOT_AN_ID + values.put(CLIENT_PENDINGID_COLUMN, UpdateHandler.NOT_AN_ID); + final SQLiteDatabase defaultDb = getDb(context, ""); + if (-1 == defaultDb.insert(CLIENT_TABLE_NAME, null, values)) { + defaultDb.update(CLIENT_TABLE_NAME, values, + CLIENT_CLIENT_ID_COLUMN + " = ?", new String[] { clientId }); + } + } + + /** + * Retrieves the list of existing client IDs. + * @param context the context to open the database + * @return a cursor containing only one column, and one client ID per line. + */ + public static Cursor queryClientIds(final Context context) { + return getDb(context, null).query(CLIENT_TABLE_NAME, + new String[] { CLIENT_CLIENT_ID_COLUMN }, null, null, null, null, null); + } + + /** + * Register a download ID for a specific metadata URI. + * + * This method should be called when a download for a metadata URI is starting. It will + * search for all clients using this metadata URI and will register for each of them + * the download ID into the database for later retrieval by + * {@link #getDownloadRecordsForDownloadId(Context, long)}. + * + * @param context a context for opening databases + * @param uri the metadata URI + * @param downloadId the download ID + */ + public static void registerMetadataDownloadId(final Context context, final String uri, + final long downloadId) { + final ContentValues values = new ContentValues(); + values.put(CLIENT_PENDINGID_COLUMN, downloadId); + values.put(CLIENT_LAST_UPDATE_DATE_COLUMN, System.currentTimeMillis()); + final SQLiteDatabase defaultDb = getDb(context, ""); + final Cursor cursor = MetadataDbHelper.queryClientIds(context); + if (null == cursor) return; + try { + if (!cursor.moveToFirst()) return; + do { + final String clientId = cursor.getString(0); + final String metadataUri = + MetadataDbHelper.getMetadataUriAsString(context, clientId); + if (metadataUri.equals(uri)) { + defaultDb.update(CLIENT_TABLE_NAME, values, + CLIENT_CLIENT_ID_COLUMN + " = ?", new String[] { clientId }); + } + } while (cursor.moveToNext()); + } finally { + cursor.close(); + } + } + + /** + * Marks a downloading entry as having successfully downloaded and being installed. + * + * The metadata database contains information about ongoing processes, typically ongoing + * downloads. This marks such an entry as having finished and having installed successfully, + * so it becomes INSTALLED. + * + * @param db the metadata database. + * @param r content values about the entry to mark as processed. + */ + public static void markEntryAsFinishedDownloadingAndInstalled(final SQLiteDatabase db, + final ContentValues r) { + switch (r.getAsInteger(TYPE_COLUMN)) { + case TYPE_BULK: + DebugLogUtils.l("Ended processing a wordlist"); + // Updating a bulk word list is a three-step operation: + // - Add the new entry to the table + // - Remove the old entry from the table + // - Erase the old file + // We start by gathering the names of the files we should delete. + final List<String> filenames = new LinkedList<>(); + final Cursor c = db.query(METADATA_TABLE_NAME, + new String[] { LOCAL_FILENAME_COLUMN }, + LOCALE_COLUMN + " = ? AND " + + WORDLISTID_COLUMN + " = ? AND " + STATUS_COLUMN + " = ?", + new String[] { r.getAsString(LOCALE_COLUMN), + r.getAsString(WORDLISTID_COLUMN), + Integer.toString(STATUS_INSTALLED) }, + null, null, null); + try { + if (c.moveToFirst()) { + // There should never be more than one file, but if there are, it's a bug + // and we should remove them all. I think it might happen if the power of + // the phone is suddenly cut during an update. + final int filenameIndex = c.getColumnIndex(LOCAL_FILENAME_COLUMN); + do { + DebugLogUtils.l("Setting for removal", c.getString(filenameIndex)); + filenames.add(c.getString(filenameIndex)); + } while (c.moveToNext()); + } + } finally { + c.close(); + } + r.put(STATUS_COLUMN, STATUS_INSTALLED); + db.beginTransactionNonExclusive(); + // Delete all old entries. There should never be any stalled entries, but if + // there are, this deletes them. + db.delete(METADATA_TABLE_NAME, + WORDLISTID_COLUMN + " = ?", + new String[] { r.getAsString(WORDLISTID_COLUMN) }); + db.insert(METADATA_TABLE_NAME, null, r); + db.setTransactionSuccessful(); + db.endTransaction(); + for (String filename : filenames) { + try { + final File f = new File(filename); + f.delete(); + } catch (SecurityException e) { + // No permissions to delete. Um. Can't do anything. + } // I don't think anything else can be thrown + } + break; + default: + // Unknown type: do nothing. + break; + } + } + + /** + * Removes a downloading entry from the database. + * + * This is invoked when a download fails. Either we tried to download, but + * we received a permanent failure and we should remove it, or we got manually + * cancelled and we should leave it at that. + * + * @param db the metadata database. + * @param id the DownloadManager id of the file. + */ + public static void deleteDownloadingEntry(final SQLiteDatabase db, final long id) { + db.delete(METADATA_TABLE_NAME, PENDINGID_COLUMN + " = ? AND " + STATUS_COLUMN + " = ?", + new String[] { Long.toString(id), Integer.toString(STATUS_DOWNLOADING) }); + } + + /** + * Forcefully removes an entry from the database. + * + * This is invoked when a file is broken. The file has been downloaded, but Android + * Keyboard is telling us it could not open it. + * + * @param db the metadata database. + * @param id the id of the word list. + * @param version the version of the word list. + */ + public static void deleteEntry(final SQLiteDatabase db, final String id, final int version) { + db.delete(METADATA_TABLE_NAME, WORDLISTID_COLUMN + " = ? AND " + VERSION_COLUMN + " = ?", + new String[] { id, Integer.toString(version) }); + } + + /** + * Internal method that sets the current status of an entry of the database. + * + * @param db the metadata database. + * @param id the id of the word list. + * @param version the version of the word list. + * @param status the status to set the word list to. + * @param downloadId an optional download id to write, or NOT_A_DOWNLOAD_ID + */ + private static void markEntryAs(final SQLiteDatabase db, final String id, + final int version, final int status, final long downloadId) { + final ContentValues values = MetadataDbHelper.getContentValuesByWordListId(db, id, version); + values.put(STATUS_COLUMN, status); + if (NOT_A_DOWNLOAD_ID != downloadId) { + values.put(MetadataDbHelper.PENDINGID_COLUMN, downloadId); + } + db.update(METADATA_TABLE_NAME, values, + WORDLISTID_COLUMN + " = ? AND " + VERSION_COLUMN + " = ?", + new String[] { id, Integer.toString(version) }); + } + + /** + * Writes the status column for the wordlist with this id as enabled. Typically this + * means the word list is currently disabled and we want to set its status to INSTALLED. + * + * @param db the metadata database. + * @param id the id of the word list. + * @param version the version of the word list. + */ + public static void markEntryAsEnabled(final SQLiteDatabase db, final String id, + final int version) { + markEntryAs(db, id, version, STATUS_INSTALLED, NOT_A_DOWNLOAD_ID); + } + + /** + * Writes the status column for the wordlist with this id as disabled. Typically this + * means the word list is currently installed and we want to set its status to DISABLED. + * + * @param db the metadata database. + * @param id the id of the word list. + * @param version the version of the word list. + */ + public static void markEntryAsDisabled(final SQLiteDatabase db, final String id, + final int version) { + markEntryAs(db, id, version, STATUS_DISABLED, NOT_A_DOWNLOAD_ID); + } + + /** + * Writes the status column for the wordlist with this id as available. This happens for + * example when a word list has been deleted but can be downloaded again. + * + * @param db the metadata database. + * @param id the id of the word list. + * @param version the version of the word list. + */ + public static void markEntryAsAvailable(final SQLiteDatabase db, final String id, + final int version) { + markEntryAs(db, id, version, STATUS_AVAILABLE, NOT_A_DOWNLOAD_ID); + } + + /** + * Writes the designated word list as downloadable, alongside with its download id. + * + * @param db the metadata database. + * @param id the id of the word list. + * @param version the version of the word list. + * @param downloadId the download id. + */ + public static void markEntryAsDownloading(final SQLiteDatabase db, final String id, + final int version, final long downloadId) { + markEntryAs(db, id, version, STATUS_DOWNLOADING, downloadId); + } + + /** + * Writes the designated word list as deleting. + * + * @param db the metadata database. + * @param id the id of the word list. + * @param version the version of the word list. + */ + public static void markEntryAsDeleting(final SQLiteDatabase db, final String id, + final int version) { + markEntryAs(db, id, version, STATUS_DELETING, NOT_A_DOWNLOAD_ID); + } + + /** + * Checks retry counts and marks the word list as retrying if retry is possible. + * + * @param db the metadata database. + * @param id the id of the word list. + * @param version the version of the word list. + * @return {@code true} if the retry is possible. + */ + public static boolean maybeMarkEntryAsRetrying(final SQLiteDatabase db, final String id, + final int version) { + final ContentValues values = MetadataDbHelper.getContentValuesByWordListId(db, id, version); + int retryCount = values.getAsInteger(MetadataDbHelper.RETRY_COUNT_COLUMN); + if (retryCount > 1) { + values.put(STATUS_COLUMN, STATUS_RETRYING); + values.put(RETRY_COUNT_COLUMN, retryCount - 1); + db.update(METADATA_TABLE_NAME, values, + WORDLISTID_COLUMN + " = ? AND " + VERSION_COLUMN + " = ?", + new String[] { id, Integer.toString(version) }); + return true; + } + return false; + } +} diff --git a/java/src/org/kelar/inputmethod/dictionarypack/MetadataHandler.java b/java/src/org/kelar/inputmethod/dictionarypack/MetadataHandler.java new file mode 100644 index 000000000..0dcd33e2d --- /dev/null +++ b/java/src/org/kelar/inputmethod/dictionarypack/MetadataHandler.java @@ -0,0 +1,173 @@ +/* + * 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.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.util.Log; + +import java.io.IOException; +import java.io.InputStreamReader; +import java.util.Collections; +import java.util.ArrayList; +import java.util.List; + +/** + * Helper class to easy up manipulation of dictionary pack metadata. + */ +public class MetadataHandler { + + public static final String TAG = MetadataHandler.class.getSimpleName(); + + // The canonical file name for metadata. This is not the name of a real file on the + // device, but a symbolic name used in the database and in metadata handling. It is never + // tested against, only used for human-readability as the file name for the metadata. + public static final String METADATA_FILENAME = "metadata.json"; + + /** + * Reads the data from the cursor and store it in metadata objects. + * @param results the cursor to read data from. + * @return the constructed list of wordlist metadata. + */ + private static List<WordListMetadata> makeMetadataObject(final Cursor results) { + final ArrayList<WordListMetadata> buildingMetadata = new ArrayList<>(); + if (null != results && results.moveToFirst()) { + final int localeColumn = results.getColumnIndex(MetadataDbHelper.LOCALE_COLUMN); + final int typeColumn = results.getColumnIndex(MetadataDbHelper.TYPE_COLUMN); + final int descriptionColumn = + results.getColumnIndex(MetadataDbHelper.DESCRIPTION_COLUMN); + final int idIndex = results.getColumnIndex(MetadataDbHelper.WORDLISTID_COLUMN); + final int updateIndex = results.getColumnIndex(MetadataDbHelper.DATE_COLUMN); + final int fileSizeIndex = results.getColumnIndex(MetadataDbHelper.FILESIZE_COLUMN); + final int rawChecksumIndex = + results.getColumnIndex(MetadataDbHelper.RAW_CHECKSUM_COLUMN); + final int checksumIndex = results.getColumnIndex(MetadataDbHelper.CHECKSUM_COLUMN); + final int retryCountIndex = results.getColumnIndex(MetadataDbHelper.RETRY_COUNT_COLUMN); + final int localFilenameIndex = + results.getColumnIndex(MetadataDbHelper.LOCAL_FILENAME_COLUMN); + final int remoteFilenameIndex = + results.getColumnIndex(MetadataDbHelper.REMOTE_FILENAME_COLUMN); + final int versionIndex = results.getColumnIndex(MetadataDbHelper.VERSION_COLUMN); + final int formatVersionIndex = + results.getColumnIndex(MetadataDbHelper.FORMATVERSION_COLUMN); + do { + buildingMetadata.add(new WordListMetadata(results.getString(idIndex), + results.getInt(typeColumn), + results.getString(descriptionColumn), + results.getLong(updateIndex), + results.getLong(fileSizeIndex), + results.getString(rawChecksumIndex), + results.getString(checksumIndex), + results.getInt(retryCountIndex), + results.getString(localFilenameIndex), + results.getString(remoteFilenameIndex), + results.getInt(versionIndex), + results.getInt(formatVersionIndex), + 0, results.getString(localeColumn))); + } while (results.moveToNext()); + } + return Collections.unmodifiableList(buildingMetadata); + } + + /** + * Gets the whole metadata, for installed and not installed dictionaries. + * @param context The context to open files over. + * @param clientId the client id for retrieving the database. null for default (deprecated) + * @return The current metadata. + */ + public static List<WordListMetadata> getCurrentMetadata(final Context context, + final String clientId) { + // If clientId is null, we get a cursor on the default database (see + // MetadataDbHelper#getInstance() for more on this) + final Cursor results = MetadataDbHelper.queryCurrentMetadata(context, clientId); + // If null, we should return makeMetadataObject(null), so we go through. + try { + return makeMetadataObject(results); + } finally { + if (null != results) { + results.close(); + } + } + } + + /** + * Gets the metadata, for a specific dictionary. + * + * @param context The context to open files over. + * @param clientId the client id for retrieving the database. null for default (deprecated). + * @param wordListId the word list ID. + * @param version the word list version. + * @return the current metaData + */ + public static WordListMetadata getCurrentMetadataForWordList(final Context context, + final String clientId, final String wordListId, final int version) { + final ContentValues contentValues = MetadataDbHelper.getContentValuesByWordListId( + MetadataDbHelper.getDb(context, clientId), wordListId, version); + if (contentValues == null) { + // TODO: Figure out why this would happen. + // Check if this happens when the metadata gets updated in the background. + Log.e(TAG, String.format( "Unable to find the current metadata for wordlist " + + "(clientId=%s, wordListId=%s, version=%d) on the database", + clientId, wordListId, version)); + return null; + } + return WordListMetadata.createFromContentValues(contentValues); + } + + /** + * Read metadata from a stream. + * @param input The stream to read from. + * @return The read metadata. + * @throws IOException if the input stream cannot be read + * @throws BadFormatException if the stream is not in a known format + */ + public static List<WordListMetadata> readMetadata(final InputStreamReader input) + throws IOException, BadFormatException { + return MetadataParser.parseMetadata(input); + } + + /** + * Finds a single WordListMetadata inside a whole metadata chunk. + * + * Searches through the whole passed metadata for the first WordListMetadata associated + * with the passed ID. If several metadata chunks with the same id are found, it will + * always return the one with the bigger FormatVersion that is less or equal than the + * maximum supported format version (as listed in UpdateHandler). + * This will NEVER return the metadata with a FormatVersion bigger than what is supported, + * even if it is the only word list with this ID. + * + * @param metadata the metadata to search into. + * @param id the word list ID of the metadata to find. + * @return the associated metadata, or null if not found. + */ + public static WordListMetadata findWordListById(final List<WordListMetadata> metadata, + final String id) { + WordListMetadata bestWordList = null; + int bestFormatVersion = Integer.MIN_VALUE; // To be sure we can't be inadvertently smaller + for (WordListMetadata wordList : metadata) { + if (id.equals(wordList.mId) + && wordList.mFormatVersion <= UpdateHandler.MAXIMUM_SUPPORTED_FORMAT_VERSION + && wordList.mFormatVersion > bestFormatVersion) { + bestWordList = wordList; + bestFormatVersion = wordList.mFormatVersion; + } + } + // If we didn't find any match we'll return null. + return bestWordList; + } +} diff --git a/java/src/org/kelar/inputmethod/dictionarypack/MetadataParser.java b/java/src/org/kelar/inputmethod/dictionarypack/MetadataParser.java new file mode 100644 index 000000000..131667f87 --- /dev/null +++ b/java/src/org/kelar/inputmethod/dictionarypack/MetadataParser.java @@ -0,0 +1,114 @@ +/* + * 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.text.TextUtils; +import android.util.JsonReader; + +import java.io.IOException; +import java.io.InputStreamReader; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.TreeMap; + +/** + * Helper class containing functions to parse the dictionary metadata. + */ +public class MetadataParser { + + // Name of the fields in the JSON-formatted file. + private static final String ID_FIELD_NAME = MetadataDbHelper.WORDLISTID_COLUMN; + private static final String LOCALE_FIELD_NAME = "locale"; + private static final String DESCRIPTION_FIELD_NAME = MetadataDbHelper.DESCRIPTION_COLUMN; + private static final String UPDATE_FIELD_NAME = "update"; + private static final String FILESIZE_FIELD_NAME = MetadataDbHelper.FILESIZE_COLUMN; + private static final String RAW_CHECKSUM_FIELD_NAME = MetadataDbHelper.RAW_CHECKSUM_COLUMN; + private static final String CHECKSUM_FIELD_NAME = MetadataDbHelper.CHECKSUM_COLUMN; + private static final String REMOTE_FILENAME_FIELD_NAME = + MetadataDbHelper.REMOTE_FILENAME_COLUMN; + private static final String VERSION_FIELD_NAME = MetadataDbHelper.VERSION_COLUMN; + private static final String FORMATVERSION_FIELD_NAME = MetadataDbHelper.FORMATVERSION_COLUMN; + + /** + * Parse one JSON-formatted word list metadata. + * @param reader the reader containing the data. + * @return a WordListMetadata object from the parsed data. + * @throws IOException if the underlying reader throws IOException during reading. + */ + private static WordListMetadata parseOneWordList(final JsonReader reader) + throws IOException, BadFormatException { + final TreeMap<String, String> arguments = new TreeMap<>(); + reader.beginObject(); + while (reader.hasNext()) { + final String name = reader.nextName(); + if (!TextUtils.isEmpty(name)) { + arguments.put(name, reader.nextString()); + } + } + reader.endObject(); + if (TextUtils.isEmpty(arguments.get(ID_FIELD_NAME)) + || TextUtils.isEmpty(arguments.get(LOCALE_FIELD_NAME)) + || TextUtils.isEmpty(arguments.get(DESCRIPTION_FIELD_NAME)) + || TextUtils.isEmpty(arguments.get(UPDATE_FIELD_NAME)) + || TextUtils.isEmpty(arguments.get(FILESIZE_FIELD_NAME)) + || TextUtils.isEmpty(arguments.get(CHECKSUM_FIELD_NAME)) + || TextUtils.isEmpty(arguments.get(REMOTE_FILENAME_FIELD_NAME)) + || TextUtils.isEmpty(arguments.get(VERSION_FIELD_NAME)) + || TextUtils.isEmpty(arguments.get(FORMATVERSION_FIELD_NAME))) { + throw new BadFormatException(arguments.toString()); + } + // TODO: need to find out whether it's bulk or update + // The null argument is the local file name, which is not known at this time and will + // be decided later. + return new WordListMetadata( + arguments.get(ID_FIELD_NAME), + MetadataDbHelper.TYPE_BULK, + arguments.get(DESCRIPTION_FIELD_NAME), + Long.parseLong(arguments.get(UPDATE_FIELD_NAME)), + Long.parseLong(arguments.get(FILESIZE_FIELD_NAME)), + arguments.get(RAW_CHECKSUM_FIELD_NAME), + arguments.get(CHECKSUM_FIELD_NAME), + MetadataDbHelper.DICTIONARY_RETRY_THRESHOLD /* retryCount */, + null, + arguments.get(REMOTE_FILENAME_FIELD_NAME), + Integer.parseInt(arguments.get(VERSION_FIELD_NAME)), + Integer.parseInt(arguments.get(FORMATVERSION_FIELD_NAME)), + 0, arguments.get(LOCALE_FIELD_NAME)); + } + + /** + * Parses metadata in the JSON format. + * @param input a stream reader expected to contain JSON formatted metadata. + * @return dictionary metadata, as an array of WordListMetadata objects. + * @throws IOException if the underlying reader throws IOException during reading. + * @throws BadFormatException if the data was not in the expected format. + */ + public static List<WordListMetadata> parseMetadata(final InputStreamReader input) + throws IOException, BadFormatException { + JsonReader reader = new JsonReader(input); + final ArrayList<WordListMetadata> readInfo = new ArrayList<>(); + reader.beginArray(); + while (reader.hasNext()) { + final WordListMetadata thisMetadata = parseOneWordList(reader); + if (!TextUtils.isEmpty(thisMetadata.mLocale)) + readInfo.add(thisMetadata); + } + return Collections.unmodifiableList(readInfo); + } + +} diff --git a/java/src/org/kelar/inputmethod/dictionarypack/MetadataUriGetter.java b/java/src/org/kelar/inputmethod/dictionarypack/MetadataUriGetter.java new file mode 100644 index 000000000..e8a79f6ca --- /dev/null +++ b/java/src/org/kelar/inputmethod/dictionarypack/MetadataUriGetter.java @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2013 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.Context; + +/** + * Helper to get the metadata URI from its base URI. + */ +@SuppressWarnings("unused") +public class MetadataUriGetter { + public static String getUri(final Context context, final String baseUri) { + return baseUri; + } +} diff --git a/java/src/org/kelar/inputmethod/dictionarypack/PrivateLog.java b/java/src/org/kelar/inputmethod/dictionarypack/PrivateLog.java new file mode 100644 index 000000000..227b4831c --- /dev/null +++ b/java/src/org/kelar/inputmethod/dictionarypack/PrivateLog.java @@ -0,0 +1,102 @@ +/* + * 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.ContentValues; +import android.content.Context; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; + +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; + +/** + * Class to keep long-term log. This is inactive in production, and is only for debug purposes. + */ +public class PrivateLog { + + public static final boolean DEBUG = DictionaryProvider.DEBUG; + + private static final String LOG_DATABASE_NAME = "log"; + private static final String LOG_TABLE_NAME = "log"; + private static final int LOG_DATABASE_VERSION = 1; + + private static final String COLUMN_DATE = "date"; + private static final String COLUMN_EVENT = "event"; + + private static final String LOG_TABLE_CREATE = "CREATE TABLE " + LOG_TABLE_NAME + " (" + + COLUMN_DATE + " TEXT," + + COLUMN_EVENT + " TEXT);"; + + static final SimpleDateFormat sDateFormat = new SimpleDateFormat( + "yyyy/MM/dd HH:mm:ss", Locale.ROOT); + + private static PrivateLog sInstance = new PrivateLog(); + private static DebugHelper sDebugHelper = null; + + private PrivateLog() { + } + + public static synchronized PrivateLog getInstance(final Context context) { + if (!DEBUG) return sInstance; + synchronized(PrivateLog.class) { + if (sDebugHelper == null) { + sDebugHelper = new DebugHelper(context); + } + return sInstance; + } + } + + static class DebugHelper extends SQLiteOpenHelper { + + DebugHelper(final Context context) { + super(context, LOG_DATABASE_NAME, null, LOG_DATABASE_VERSION); + } + + @Override + public void onCreate(SQLiteDatabase db) { + if (!DEBUG) return; + db.execSQL(LOG_TABLE_CREATE); + insert(db, "Created table"); + } + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + if (!DEBUG) return; + // Remove all data. + db.execSQL("DROP TABLE IF EXISTS " + LOG_TABLE_NAME); + onCreate(db); + insert(db, "Upgrade finished"); + } + + static void insert(SQLiteDatabase db, String event) { + if (!DEBUG) return; + final ContentValues c = new ContentValues(2); + c.put(COLUMN_DATE, sDateFormat.format(new Date(System.currentTimeMillis()))); + c.put(COLUMN_EVENT, event); + db.insert(LOG_TABLE_NAME, null, c); + } + + } + + public static void log(String event) { + if (!DEBUG) return; + final SQLiteDatabase l = sDebugHelper.getWritableDatabase(); + DebugHelper.insert(l, event); + } +} diff --git a/java/src/org/kelar/inputmethod/dictionarypack/ProblemReporter.java b/java/src/org/kelar/inputmethod/dictionarypack/ProblemReporter.java new file mode 100644 index 000000000..6690a79c1 --- /dev/null +++ b/java/src/org/kelar/inputmethod/dictionarypack/ProblemReporter.java @@ -0,0 +1,24 @@ +/* + * 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; + +/** + * A simple interface to report problems. + */ +public interface ProblemReporter { + public void report(Exception e); +} diff --git a/java/src/org/kelar/inputmethod/dictionarypack/UpdateHandler.java b/java/src/org/kelar/inputmethod/dictionarypack/UpdateHandler.java new file mode 100644 index 000000000..c2c785560 --- /dev/null +++ b/java/src/org/kelar/inputmethod/dictionarypack/UpdateHandler.java @@ -0,0 +1,1082 @@ +/* + * 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.app.DownloadManager; +import android.app.DownloadManager.Query; +import android.app.DownloadManager.Request; +import android.content.ContentValues; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.res.Resources; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.net.Uri; +import android.os.ParcelFileDescriptor; +import android.provider.Settings; +import android.text.TextUtils; +import android.util.Log; + +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.makedict.FormatSpec; +import org.kelar.inputmethod.latin.utils.ApplicationUtils; +import org.kelar.inputmethod.latin.utils.DebugLogUtils; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.nio.channels.FileChannel; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; + +import javax.annotation.Nullable; + +/** + * Handler for the update process. + * + * This class is in charge of coordinating the update process for the various dictionaries + * stored in the dictionary pack. + */ +public final class UpdateHandler { + static final String TAG = "DictionaryProvider:" + UpdateHandler.class.getSimpleName(); + private static final boolean DEBUG = DictionaryProvider.DEBUG; + + // Used to prevent trying to read the id of the downloaded file before it is written + static final Object sSharedIdProtector = new Object(); + + // Value used to mean this is not a real DownloadManager downloaded file id + // DownloadManager uses as an ID numbers returned out of an AUTOINCREMENT column + // in SQLite, so it should never return anything < 0. + public static final int NOT_AN_ID = -1; + public static final int MAXIMUM_SUPPORTED_FORMAT_VERSION = + FormatSpec.MAXIMUM_SUPPORTED_STATIC_VERSION; + + // Arbitrary. Probably good if it's a power of 2, and a couple thousand bytes long. + private static final int FILE_COPY_BUFFER_SIZE = 8192; + + // Table fixed values for metadata / downloads + final static String METADATA_NAME = "metadata"; + final static int METADATA_TYPE = 0; + final static int WORDLIST_TYPE = 1; + + // Suffix for generated dictionary files + private static final String DICT_FILE_SUFFIX = ".dict"; + // Name of the category for the main dictionary + public static final String MAIN_DICTIONARY_CATEGORY = "main"; + + public static final String TEMP_DICT_FILE_SUB = "___"; + + // The id for the "dictionary available" notification. + static final int DICT_AVAILABLE_NOTIFICATION_ID = 1; + + /** + * An interface for UIs or services that want to know when something happened. + * + * This is chiefly used by the dictionary manager UI. + */ + public interface UpdateEventListener { + void downloadedMetadata(boolean succeeded); + void wordListDownloadFinished(String wordListId, boolean succeeded); + void updateCycleCompleted(); + } + + /** + * The list of currently registered listeners. + */ + private static List<UpdateEventListener> sUpdateEventListeners + = Collections.synchronizedList(new LinkedList<UpdateEventListener>()); + + /** + * Register a new listener to be notified of updates. + * + * Don't forget to call unregisterUpdateEventListener when done with it, or + * it will leak the register. + */ + public static void registerUpdateEventListener(final UpdateEventListener listener) { + sUpdateEventListeners.add(listener); + } + + /** + * Unregister a previously registered listener. + */ + public static void unregisterUpdateEventListener(final UpdateEventListener listener) { + sUpdateEventListeners.remove(listener); + } + + private static final String DOWNLOAD_OVER_METERED_SETTING_PREFS_KEY = "downloadOverMetered"; + + /** + * Write the DownloadManager ID of the currently downloading metadata to permanent storage. + * + * @param context to open shared prefs + * @param uri the uri of the metadata + * @param downloadId the id returned by DownloadManager + */ + private static void writeMetadataDownloadId(final Context context, final String uri, + final long downloadId) { + MetadataDbHelper.registerMetadataDownloadId(context, uri, downloadId); + } + + public static final int DOWNLOAD_OVER_METERED_SETTING_UNKNOWN = 0; + public static final int DOWNLOAD_OVER_METERED_ALLOWED = 1; + public static final int DOWNLOAD_OVER_METERED_DISALLOWED = 2; + + /** + * Sets the setting that tells us whether we may download over a metered connection. + */ + public static void setDownloadOverMeteredSetting(final Context context, + final boolean shouldDownloadOverMetered) { + final SharedPreferences prefs = CommonPreferences.getCommonPreferences(context); + final SharedPreferences.Editor editor = prefs.edit(); + editor.putInt(DOWNLOAD_OVER_METERED_SETTING_PREFS_KEY, shouldDownloadOverMetered + ? DOWNLOAD_OVER_METERED_ALLOWED : DOWNLOAD_OVER_METERED_DISALLOWED); + editor.apply(); + } + + /** + * Gets the setting that tells us whether we may download over a metered connection. + * + * This returns one of the constants above. + */ + public static int getDownloadOverMeteredSetting(final Context context) { + final SharedPreferences prefs = CommonPreferences.getCommonPreferences(context); + final int setting = prefs.getInt(DOWNLOAD_OVER_METERED_SETTING_PREFS_KEY, + DOWNLOAD_OVER_METERED_SETTING_UNKNOWN); + return setting; + } + + /** + * Download latest metadata from the server through DownloadManager for all known clients + * @param context The context for retrieving resources + * @return true if an update successfully started, false otherwise. + */ + public static boolean tryUpdate(final Context context) { + // TODO: loop through all clients instead of only doing the default one. + final TreeSet<String> uris = new TreeSet<>(); + final Cursor cursor = MetadataDbHelper.queryClientIds(context); + if (null == cursor) return false; + try { + if (!cursor.moveToFirst()) return false; + do { + final String clientId = cursor.getString(0); + final String metadataUri = + MetadataDbHelper.getMetadataUriAsString(context, clientId); + PrivateLog.log("Update for clientId " + DebugLogUtils.s(clientId)); + DebugLogUtils.l("Update for clientId", clientId, " which uses URI ", metadataUri); + uris.add(metadataUri); + } while (cursor.moveToNext()); + } finally { + cursor.close(); + } + boolean started = false; + for (final String metadataUri : uris) { + if (!TextUtils.isEmpty(metadataUri)) { + // If the metadata URI is empty, that means we should never update it at all. + // It should not be possible to come here with a null metadata URI, because + // it should have been rejected at the time of client registration; if there + // is a bug and it happens anyway, doing nothing is the right thing to do. + // For more information, {@see DictionaryProvider#insert(Uri, ContentValues)}. + updateClientsWithMetadataUri(context, metadataUri); + started = true; + } + } + return started; + } + + /** + * Download latest metadata from the server through DownloadManager for all relevant clients + * + * @param context The context for retrieving resources + * @param metadataUri The client to update + */ + private static void updateClientsWithMetadataUri( + final Context context, final String metadataUri) { + Log.i(TAG, "updateClientsWithMetadataUri() : MetadataUri = " + metadataUri); + // Adding a disambiguator to circumvent a bug in older versions of DownloadManager. + // DownloadManager also stupidly cuts the extension to replace with its own that it + // gets from the content-type. We need to circumvent this. + final String disambiguator = "#" + System.currentTimeMillis() + + ApplicationUtils.getVersionName(context) + ".json"; + final Request metadataRequest = new Request(Uri.parse(metadataUri + disambiguator)); + DebugLogUtils.l("Request =", metadataRequest); + + final Resources res = context.getResources(); + metadataRequest.setAllowedNetworkTypes(Request.NETWORK_WIFI | Request.NETWORK_MOBILE); + metadataRequest.setTitle(res.getString(R.string.download_description)); + // Do not show the notification when downloading the metadata. + metadataRequest.setNotificationVisibility(Request.VISIBILITY_HIDDEN); + metadataRequest.setVisibleInDownloadsUi( + res.getBoolean(R.bool.metadata_downloads_visible_in_download_UI)); + + final DownloadManagerWrapper manager = new DownloadManagerWrapper(context); + if (maybeCancelUpdateAndReturnIfStillRunning(context, metadataUri, manager, + DictionaryService.NO_CANCEL_DOWNLOAD_PERIOD_MILLIS)) { + // We already have a recent download in progress. Don't register a new download. + return; + } + final long downloadId; + synchronized (sSharedIdProtector) { + downloadId = manager.enqueue(metadataRequest); + DebugLogUtils.l("Metadata download requested with id", downloadId); + // If there is still a download in progress, it's been there for a while and + // there is probably something wrong with download manager. It's best to just + // overwrite the id and request it again. If the old one happens to finish + // anyway, we don't know about its ID any more, so the downloadFinished + // method will ignore it. + writeMetadataDownloadId(context, metadataUri, downloadId); + } + Log.i(TAG, "updateClientsWithMetadataUri() : DownloadId = " + downloadId); + } + + /** + * Cancels downloading a file if there is one for this URI and it's too long. + * + * If we are not currently downloading the file at this URI, this is a no-op. + * + * @param context the context to open the database on + * @param metadataUri the URI to cancel + * @param manager an wrapped instance of DownloadManager + * @param graceTime if there was a download started less than this many milliseconds, don't + * cancel and return true + * @return whether the download is still active + */ + private static boolean maybeCancelUpdateAndReturnIfStillRunning(final Context context, + final String metadataUri, final DownloadManagerWrapper manager, final long graceTime) { + synchronized (sSharedIdProtector) { + final DownloadIdAndStartDate metadataDownloadIdAndStartDate = + MetadataDbHelper.getMetadataDownloadIdAndStartDateForURI(context, metadataUri); + if (null == metadataDownloadIdAndStartDate) return false; + if (NOT_AN_ID == metadataDownloadIdAndStartDate.mId) return false; + if (metadataDownloadIdAndStartDate.mStartDate + graceTime + > System.currentTimeMillis()) { + return true; + } + manager.remove(metadataDownloadIdAndStartDate.mId); + writeMetadataDownloadId(context, metadataUri, NOT_AN_ID); + } + // Consider a cancellation as a failure. As such, inform listeners that the download + // has failed. + for (UpdateEventListener listener : linkedCopyOfList(sUpdateEventListeners)) { + listener.downloadedMetadata(false); + } + return false; + } + + /** + * Cancels a pending update for this client, if there is one. + * + * If we are not currently updating metadata for this client, this is a no-op. This is a helper + * method that gets the download manager service and the metadata URI for this client. + * + * @param context the context, to get an instance of DownloadManager + * @param clientId the ID of the client we want to cancel the update of + */ + public static void cancelUpdate(final Context context, final String clientId) { + final DownloadManagerWrapper manager = new DownloadManagerWrapper(context); + final String metadataUri = MetadataDbHelper.getMetadataUriAsString(context, clientId); + maybeCancelUpdateAndReturnIfStillRunning(context, metadataUri, manager, 0 /* graceTime */); + } + + /** + * Registers a download request and flags it as downloading in the metadata table. + * + * This is a helper method that exists to avoid race conditions where DownloadManager might + * finish downloading the file before the data is committed to the database. + * It registers the request with the DownloadManager service and also updates the metadata + * database directly within a synchronized section. + * This method has no intelligence about the data it commits to the database aside from the + * download request id, which is not known before submitting the request to the download + * manager. Hence, it only updates the relevant line. + * + * @param manager a wrapped download manager service to register the request with. + * @param request the request to register. + * @param db the metadata database. + * @param id the id of the word list. + * @param version the version of the word list. + * @return the download id returned by the download manager. + */ + public static long registerDownloadRequest(final DownloadManagerWrapper manager, + final Request request, final SQLiteDatabase db, final String id, final int version) { + Log.i(TAG, "registerDownloadRequest() : Id = " + id + " : Version = " + version); + final long downloadId; + synchronized (sSharedIdProtector) { + downloadId = manager.enqueue(request); + Log.i(TAG, "registerDownloadRequest() : DownloadId = " + downloadId); + MetadataDbHelper.markEntryAsDownloading(db, id, version, downloadId); + } + return downloadId; + } + + /** + * Retrieve information about a specific download from DownloadManager. + */ + private static CompletedDownloadInfo getCompletedDownloadInfo( + final DownloadManagerWrapper manager, final long downloadId) { + final Query query = new Query().setFilterById(downloadId); + final Cursor cursor = manager.query(query); + + if (null == cursor) { + return new CompletedDownloadInfo(null, downloadId, DownloadManager.STATUS_FAILED); + } + try { + final String uri; + final int status; + if (cursor.moveToNext()) { + final int columnStatus = cursor.getColumnIndex(DownloadManager.COLUMN_STATUS); + final int columnError = cursor.getColumnIndex(DownloadManager.COLUMN_REASON); + final int columnUri = cursor.getColumnIndex(DownloadManager.COLUMN_URI); + final int error = cursor.getInt(columnError); + status = cursor.getInt(columnStatus); + final String uriWithAnchor = cursor.getString(columnUri); + int anchorIndex = uriWithAnchor.indexOf('#'); + if (anchorIndex != -1) { + uri = uriWithAnchor.substring(0, anchorIndex); + } else { + uri = uriWithAnchor; + } + if (DownloadManager.STATUS_SUCCESSFUL != status) { + Log.e(TAG, "Permanent failure of download " + downloadId + + " with error code: " + error); + } + } else { + uri = null; + status = DownloadManager.STATUS_FAILED; + } + return new CompletedDownloadInfo(uri, downloadId, status); + } finally { + cursor.close(); + } + } + + private static ArrayList<DownloadRecord> getDownloadRecordsForCompletedDownloadInfo( + final Context context, final CompletedDownloadInfo downloadInfo) { + // Get and check the ID of the file we are waiting for, compare them to downloaded ones + synchronized(sSharedIdProtector) { + final ArrayList<DownloadRecord> downloadRecords = + MetadataDbHelper.getDownloadRecordsForDownloadId(context, + downloadInfo.mDownloadId); + // If any of these is metadata, we should update the DB + boolean hasMetadata = false; + for (DownloadRecord record : downloadRecords) { + if (record.isMetadata()) { + hasMetadata = true; + break; + } + } + if (hasMetadata) { + writeMetadataDownloadId(context, downloadInfo.mUri, NOT_AN_ID); + MetadataDbHelper.saveLastUpdateTimeOfUri(context, downloadInfo.mUri); + } + return downloadRecords; + } + } + + /** + * Take appropriate action after a download finished, in success or in error. + * + * This is called by the system upon broadcast from the DownloadManager that a file + * has been downloaded successfully. + * After a simple check that this is actually the file we are waiting for, this + * method basically coordinates the parsing and comparison of metadata, and fires + * the computation of the list of actions that should be taken then executes them. + * + * @param context The context for this action. + * @param intent The intent from the DownloadManager containing details about the download. + */ + /* package */ static void downloadFinished(final Context context, final Intent intent) { + // Get and check the ID of the file that was downloaded + final long fileId = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, NOT_AN_ID); + Log.i(TAG, "downloadFinished() : DownloadId = " + fileId); + if (NOT_AN_ID == fileId) return; // Spurious wake-up: ignore + + final DownloadManagerWrapper manager = new DownloadManagerWrapper(context); + final CompletedDownloadInfo downloadInfo = getCompletedDownloadInfo(manager, fileId); + + final ArrayList<DownloadRecord> recordList = + getDownloadRecordsForCompletedDownloadInfo(context, downloadInfo); + if (null == recordList) return; // It was someone else's download. + DebugLogUtils.l("Received result for download ", fileId); + + // TODO: handle gracefully a null pointer here. This is practically impossible because + // we come here only when DownloadManager explicitly called us when it ended a + // download, so we are pretty sure it's alive. It's theoretically possible that it's + // disabled right inbetween the firing of the intent and the control reaching here. + + for (final DownloadRecord record : recordList) { + // downloadSuccessful is not final because we may still have exceptions from now on + boolean downloadSuccessful = false; + try { + if (downloadInfo.wasSuccessful()) { + downloadSuccessful = handleDownloadedFile(context, record, manager, fileId); + Log.i(TAG, "downloadFinished() : Success = " + downloadSuccessful); + } + } finally { + final String resultMessage = downloadSuccessful ? "Success" : "Failure"; + if (record.isMetadata()) { + Log.i(TAG, "downloadFinished() : Metadata " + resultMessage); + publishUpdateMetadataCompleted(context, downloadSuccessful); + } else { + Log.i(TAG, "downloadFinished() : WordList " + resultMessage); + final SQLiteDatabase db = MetadataDbHelper.getDb(context, record.mClientId); + publishUpdateWordListCompleted(context, downloadSuccessful, fileId, + db, record.mAttributes, record.mClientId); + } + } + } + // Now that we're done using it, we can remove this download from DLManager + manager.remove(fileId); + } + + /** + * Sends a broadcast informing listeners that the dictionaries were updated. + * + * This will call all local listeners through the UpdateEventListener#downloadedMetadata + * callback (for example, the dictionary provider interface uses this to stop the Loading + * animation) and send a broadcast about the metadata having been updated. For a client of + * the dictionary pack like Latin IME, this means it should re-query the dictionary pack + * for any relevant new data. + * + * @param context the context, to send the broadcast. + * @param downloadSuccessful whether the download of the metadata was successful or not. + */ + public static void publishUpdateMetadataCompleted(final Context context, + final boolean downloadSuccessful) { + // We need to warn all listeners of what happened. But some listeners may want to + // remove themselves or re-register something in response. Hence we should take a + // snapshot of the listener list and warn them all. This also prevents any + // concurrent modification problem of the static list. + for (UpdateEventListener listener : linkedCopyOfList(sUpdateEventListeners)) { + listener.downloadedMetadata(downloadSuccessful); + } + publishUpdateCycleCompletedEvent(context); + } + + private static void publishUpdateWordListCompleted(final Context context, + final boolean downloadSuccessful, final long fileId, + final SQLiteDatabase db, final ContentValues downloadedFileRecord, + final String clientId) { + synchronized(sSharedIdProtector) { + if (downloadSuccessful) { + final ActionBatch actions = new ActionBatch(); + actions.add(new ActionBatch.InstallAfterDownloadAction(clientId, + downloadedFileRecord)); + actions.execute(context, new LogProblemReporter(TAG)); + } else { + MetadataDbHelper.deleteDownloadingEntry(db, fileId); + } + } + // See comment above about #linkedCopyOfLists + for (UpdateEventListener listener : linkedCopyOfList(sUpdateEventListeners)) { + listener.wordListDownloadFinished(downloadedFileRecord.getAsString( + MetadataDbHelper.WORDLISTID_COLUMN), downloadSuccessful); + } + publishUpdateCycleCompletedEvent(context); + } + + private static void publishUpdateCycleCompletedEvent(final Context context) { + // Even if this is not successful, we have to publish the new state. + PrivateLog.log("Publishing update cycle completed event"); + DebugLogUtils.l("Publishing update cycle completed event"); + for (UpdateEventListener listener : linkedCopyOfList(sUpdateEventListeners)) { + listener.updateCycleCompleted(); + } + signalNewDictionaryState(context); + } + + private static boolean handleDownloadedFile(final Context context, + final DownloadRecord downloadRecord, final DownloadManagerWrapper manager, + final long fileId) { + try { + // {@link handleWordList(Context,InputStream,ContentValues)}. + // Handle the downloaded file according to its type + if (downloadRecord.isMetadata()) { + DebugLogUtils.l("Data D/L'd is metadata for", downloadRecord.mClientId); + // #handleMetadata() closes its InputStream argument + handleMetadata(context, new ParcelFileDescriptor.AutoCloseInputStream( + manager.openDownloadedFile(fileId)), downloadRecord.mClientId); + } else { + DebugLogUtils.l("Data D/L'd is a word list"); + final int wordListStatus = downloadRecord.mAttributes.getAsInteger( + MetadataDbHelper.STATUS_COLUMN); + if (MetadataDbHelper.STATUS_DOWNLOADING == wordListStatus) { + // #handleWordList() closes its InputStream argument + handleWordList(context, new ParcelFileDescriptor.AutoCloseInputStream( + manager.openDownloadedFile(fileId)), downloadRecord); + } else { + Log.e(TAG, "Spurious download ended. Maybe a cancelled download?"); + } + } + return true; + } catch (FileNotFoundException e) { + Log.e(TAG, "A file was downloaded but it can't be opened", e); + } catch (IOException e) { + // Can't read the file... disk damage? + Log.e(TAG, "Can't read a file", e); + // TODO: Check with UX how we should warn the user. + } catch (IllegalStateException e) { + // The format of the downloaded file is incorrect. We should maybe report upstream? + Log.e(TAG, "Incorrect data received", e); + } catch (BadFormatException e) { + // The format of the downloaded file is incorrect. We should maybe report upstream? + Log.e(TAG, "Incorrect data received", e); + } + return false; + } + + /** + * Returns a copy of the specified list, with all elements copied. + * + * This returns a linked list. + */ + private static <T> List<T> linkedCopyOfList(final List<T> src) { + // Instantiation of a parameterized type is not possible in Java, so it's not possible to + // return the same type of list that was passed - probably the same reason why Collections + // does not do it. So we need to decide statically which concrete type to return. + return new LinkedList<>(src); + } + + /** + * Warn Kelar Keyboard that the state of dictionaries changed and it should refresh its data. + */ + private static void signalNewDictionaryState(final Context context) { + // TODO: Also provide the locale of the updated dictionary so that the LatinIme + // does not have to reset if it is a different locale. + final Intent newDictBroadcast = + new Intent(DictionaryPackConstants.NEW_DICTIONARY_INTENT_ACTION); + context.sendBroadcast(newDictBroadcast); + } + + /** + * Parse metadata and take appropriate action (that is, upgrade dictionaries). + * @param context the context to read settings. + * @param stream an input stream pointing to the downloaded data. May not be null. + * Will be closed upon finishing. + * @param clientId the ID of the client to update + * @throws BadFormatException if the metadata is not in a known format. + * @throws IOException if the downloaded file can't be read from the disk + */ + public static void handleMetadata(final Context context, final InputStream stream, + final String clientId) throws IOException, BadFormatException { + DebugLogUtils.l("Entering handleMetadata"); + final List<WordListMetadata> newMetadata; + final InputStreamReader reader = new InputStreamReader(stream); + try { + // According to the doc InputStreamReader buffers, so no need to add a buffering layer + newMetadata = MetadataHandler.readMetadata(reader); + } finally { + reader.close(); + } + + DebugLogUtils.l("Downloaded metadata :", newMetadata); + PrivateLog.log("Downloaded metadata\n" + newMetadata); + + final ActionBatch actions = computeUpgradeTo(context, clientId, newMetadata); + // TODO: Check with UX how we should report to the user + // TODO: add an action to close the database + actions.execute(context, new LogProblemReporter(TAG)); + } + + /** + * Handle a word list: put it in its right place, and update the passed content values. + * @param context the context for opening files. + * @param inputStream an input stream pointing to the downloaded data. May not be null. + * Will be closed upon finishing. + * @param downloadRecord the content values to fill the file name in. + * @throws IOException if files can't be read or written. + * @throws BadFormatException if the md5 checksum doesn't match the metadata. + */ + private static void handleWordList(final Context context, + final InputStream inputStream, final DownloadRecord downloadRecord) + throws IOException, BadFormatException { + + // DownloadManager does not have the ability to put the file directly where we want + // it, so we had it download to a temporary place. Now we move it. It will be deleted + // automatically by DownloadManager. + DebugLogUtils.l("Downloaded a new word list :", downloadRecord.mAttributes.getAsString( + MetadataDbHelper.DESCRIPTION_COLUMN), "for", downloadRecord.mClientId); + PrivateLog.log("Downloaded a new word list with description : " + + downloadRecord.mAttributes.getAsString(MetadataDbHelper.DESCRIPTION_COLUMN) + + " for " + downloadRecord.mClientId); + + final String locale = + downloadRecord.mAttributes.getAsString(MetadataDbHelper.LOCALE_COLUMN); + final String destinationFile = getTempFileName(context, locale); + downloadRecord.mAttributes.put(MetadataDbHelper.LOCAL_FILENAME_COLUMN, destinationFile); + + FileOutputStream outputStream = null; + try { + outputStream = context.openFileOutput(destinationFile, Context.MODE_PRIVATE); + copyFile(inputStream, outputStream); + } finally { + inputStream.close(); + if (outputStream != null) { + outputStream.close(); + } + } + + // TODO: Consolidate this MD5 calculation with file copying above. + // We need to reopen the file because the inputstream bytes have been consumed, and there + // is nothing in InputStream to reopen or rewind the stream + FileInputStream copiedFile = null; + final String md5sum; + try { + copiedFile = context.openFileInput(destinationFile); + md5sum = MD5Calculator.checksum(copiedFile); + } finally { + if (copiedFile != null) { + copiedFile.close(); + } + } + if (TextUtils.isEmpty(md5sum)) { + return; // We can't compute the checksum anyway, so return and hope for the best + } + if (!md5sum.equals(downloadRecord.mAttributes.getAsString( + MetadataDbHelper.CHECKSUM_COLUMN))) { + context.deleteFile(destinationFile); + throw new BadFormatException("MD5 checksum check failed : \"" + md5sum + "\" <> \"" + + downloadRecord.mAttributes.getAsString(MetadataDbHelper.CHECKSUM_COLUMN) + + "\""); + } + } + + /** + * Copies in to out using FileChannels. + * + * This tries to use channels for fast copying. If it doesn't work, fall back to + * copyFileFallBack below. + * + * @param in the stream to copy from. + * @param out the stream to copy to. + * @throws IOException if both the normal and fallback methods raise exceptions. + */ + private static void copyFile(final InputStream in, final OutputStream out) + throws IOException { + DebugLogUtils.l("Copying files"); + if (!(in instanceof FileInputStream) || !(out instanceof FileOutputStream)) { + DebugLogUtils.l("Not the right types"); + copyFileFallback(in, out); + } else { + try { + final FileChannel sourceChannel = ((FileInputStream) in).getChannel(); + final FileChannel destinationChannel = ((FileOutputStream) out).getChannel(); + sourceChannel.transferTo(0, Integer.MAX_VALUE, destinationChannel); + } catch (IOException e) { + // Can't work with channels, or something went wrong. Copy by hand. + DebugLogUtils.l("Won't work"); + copyFileFallback(in, out); + } + } + } + + /** + * Copies in to out with read/write methods, not FileChannels. + * + * @param in the stream to copy from. + * @param out the stream to copy to. + * @throws IOException if a read or a write fails. + */ + private static void copyFileFallback(final InputStream in, final OutputStream out) + throws IOException { + DebugLogUtils.l("Falling back to slow copy"); + final byte[] buffer = new byte[FILE_COPY_BUFFER_SIZE]; + for (int readBytes = in.read(buffer); readBytes >= 0; readBytes = in.read(buffer)) + out.write(buffer, 0, readBytes); + } + + /** + * Creates and returns a new file to store a dictionary + * @param context the context to use to open the file. + * @param locale the locale for this dictionary, to make the file name more readable. + * @return the file name, or throw an exception. + * @throws IOException if the file cannot be created. + */ + private static String getTempFileName(final Context context, final String locale) + throws IOException { + DebugLogUtils.l("Entering openTempFileOutput"); + final File dir = context.getFilesDir(); + final File f = File.createTempFile(locale + TEMP_DICT_FILE_SUB, DICT_FILE_SUFFIX, dir); + DebugLogUtils.l("File name is", f.getName()); + return f.getName(); + } + + /** + * Compare metadata (collections of word lists). + * + * This method takes whole metadata sets directly and compares them, matching the wordlists in + * each of them on the id. It creates an ActionBatch object that can be .execute()'d to perform + * the actual upgrade from `from' to `to'. + * + * @param context the context to open databases on. + * @param clientId the id of the client. + * @param from the dictionary descriptor (as a list of wordlists) to upgrade from. + * @param to the dictionary descriptor (as a list of wordlists) to upgrade to. + * @return an ordered list of runnables to be called to upgrade. + */ + private static ActionBatch compareMetadataForUpgrade(final Context context, + final String clientId, @Nullable final List<WordListMetadata> from, + @Nullable final List<WordListMetadata> to) { + final ActionBatch actions = new ActionBatch(); + // Upgrade existing word lists + DebugLogUtils.l("Comparing dictionaries"); + final Set<String> wordListIds = new TreeSet<>(); + // TODO: Can these be null? + final List<WordListMetadata> fromList = (from == null) ? new ArrayList<WordListMetadata>() + : from; + final List<WordListMetadata> toList = (to == null) ? new ArrayList<WordListMetadata>() + : to; + for (WordListMetadata wlData : fromList) wordListIds.add(wlData.mId); + for (WordListMetadata wlData : toList) wordListIds.add(wlData.mId); + for (String id : wordListIds) { + final WordListMetadata currentInfo = MetadataHandler.findWordListById(fromList, id); + final WordListMetadata metadataInfo = MetadataHandler.findWordListById(toList, id); + // TODO: Remove the following unnecessary check, since we are now doing the filtering + // inside findWordListById. + final WordListMetadata newInfo = null == metadataInfo + || metadataInfo.mFormatVersion > MAXIMUM_SUPPORTED_FORMAT_VERSION + ? null : metadataInfo; + DebugLogUtils.l("Considering updating ", id, "currentInfo =", currentInfo); + + if (null == currentInfo && null == newInfo) { + // This may happen if a new word list appeared that we can't handle. + if (null == metadataInfo) { + // What happened? Bug in Set<>? + Log.e(TAG, "Got an id for a wordlist that is neither in from nor in to"); + } else { + // We may come here if there is a new word list that we can't handle. + Log.i(TAG, "Can't handle word list with id '" + id + "' because it has format" + + " version " + metadataInfo.mFormatVersion + " and the maximum version" + + " we can handle is " + MAXIMUM_SUPPORTED_FORMAT_VERSION); + } + continue; + } else if (null == currentInfo) { + // This is the case where a new list that we did not know of popped on the server. + // Make it available. + actions.add(new ActionBatch.MakeAvailableAction(clientId, newInfo)); + } else if (null == newInfo) { + // This is the case where an old list we had is not in the server data any more. + // Pass false to ForgetAction: this may be installed and we still want to apply + // a forget-like action (remove the URL) if it is, so we want to turn off the + // status == AVAILABLE check. If it's DELETING, this is the right thing to do, + // as we want to leave the record as long as Kelar Keyboard has not deleted it ; + // the record will be removed when the file is actually deleted. + actions.add(new ActionBatch.ForgetAction(clientId, currentInfo, false)); + } else { + final SQLiteDatabase db = MetadataDbHelper.getDb(context, clientId); + if (newInfo.mVersion == currentInfo.mVersion) { + if (TextUtils.equals(newInfo.mRemoteFilename, currentInfo.mRemoteFilename)) { + // If the dictionary url hasn't changed, we should preserve the retryCount. + newInfo.mRetryCount = currentInfo.mRetryCount; + } + // If it's the same id/version, we update the DB with the new values. + // It doesn't matter too much if they didn't change. + actions.add(new ActionBatch.UpdateDataAction(clientId, newInfo)); + } else if (newInfo.mVersion > currentInfo.mVersion) { + // If it's a new version, it's a different entry in the database. Make it + // available, and if it's installed, also start the download. + final ContentValues values = MetadataDbHelper.getContentValuesByWordListId(db, + currentInfo.mId, currentInfo.mVersion); + final int status = values.getAsInteger(MetadataDbHelper.STATUS_COLUMN); + actions.add(new ActionBatch.MakeAvailableAction(clientId, newInfo)); + if (status == MetadataDbHelper.STATUS_INSTALLED + || status == MetadataDbHelper.STATUS_DISABLED) { + actions.add(new ActionBatch.StartDownloadAction(clientId, newInfo)); + } else { + // Pass true to ForgetAction: this is indeed an update to a non-installed + // word list, so activate status == AVAILABLE check + // In case the status is DELETING, this is the right thing to do. It will + // leave the entry as DELETING and remove its URL so that Kelar Keyboard + // can delete it the next time it starts up. + actions.add(new ActionBatch.ForgetAction(clientId, currentInfo, true)); + } + } else if (DEBUG) { + Log.i(TAG, "Not updating word list " + id + + " : current list timestamp is " + currentInfo.mLastUpdate + + " ; new list timestamp is " + newInfo.mLastUpdate); + } + } + } + return actions; + } + + /** + * Computes an upgrade from the current state of the dictionaries to some desired state. + * @param context the context for reading settings and files. + * @param clientId the id of the client. + * @param newMetadata the state we want to upgrade to. + * @return the upgrade from the current state to the desired state, ready to be executed. + */ + public static ActionBatch computeUpgradeTo(final Context context, final String clientId, + final List<WordListMetadata> newMetadata) { + final List<WordListMetadata> currentMetadata = + MetadataHandler.getCurrentMetadata(context, clientId); + return compareMetadataForUpgrade(context, clientId, currentMetadata, newMetadata); + } + + /** + * Installs a word list if it has never been requested. + * + * This is called when a word list is requested, and is available but not installed. It checks + * the conditions for auto-installation: if the dictionary is a main dictionary for this + * language, and it has never been opted out through the dictionary interface, then we start + * installing it. For the user who enables a language and uses it for the first time, the + * dictionary should magically start being used a short time after they start typing. + * The mayPrompt argument indicates whether we should prompt the user for a decision to + * download or not, in case we decide we are in the case where we should download - this + * roughly happens when the current connectivity is 3G. See + * DictionaryProvider#getDictionaryWordListsForContentUri for details. + */ + // As opposed to many other methods, this method does not need the version of the word + // list because it may only install the latest version we know about for this specific + // word list ID / client ID combination. + public static void installIfNeverRequested(final Context context, final String clientId, + final String wordlistId) { + Log.i(TAG, "installIfNeverRequested() : ClientId = " + clientId + + " : WordListId = " + wordlistId); + final String[] idArray = wordlistId.split(DictionaryProvider.ID_CATEGORY_SEPARATOR); + // If we have a new-format dictionary id (category:manual_id), then use the + // specified category. Otherwise, it is a main dictionary, so force the + // MAIN category upon it. + final String category = 2 == idArray.length ? idArray[0] : MAIN_DICTIONARY_CATEGORY; + if (!MAIN_DICTIONARY_CATEGORY.equals(category)) { + // Not a main dictionary. We only auto-install main dictionaries, so we can return now. + return; + } + if (CommonPreferences.getCommonPreferences(context).contains(wordlistId)) { + // If some kind of settings has been done in the past for this specific id, then + // this is not a candidate for auto-install. Because it already is either true, + // in which case it may be installed or downloading or whatever, and we don't + // need to care about it because it's already handled or being handled, or it's false + // in which case it means the user explicitely turned it off and don't want to have + // it installed. So we quit right away. + return; + } + + final SQLiteDatabase db = MetadataDbHelper.getDb(context, clientId); + final ContentValues installCandidate = + MetadataDbHelper.getContentValuesOfLatestAvailableWordlistById(db, wordlistId); + if (MetadataDbHelper.STATUS_AVAILABLE + != installCandidate.getAsInteger(MetadataDbHelper.STATUS_COLUMN)) { + // If it's not "AVAILABLE", we want to stop now. Because candidates for auto-install + // are lists that we know are available, but we also know have never been installed. + // It does obviously not concern already installed lists, or downloading lists, + // or those that have been disabled, flagged as deleting... So anything else than + // AVAILABLE means we don't auto-install. + return; + } + + // We decided against prompting the user for a decision. This may be because we were + // explicitly asked not to, or because we are currently on wi-fi anyway, or because we + // already know the answer to the question. We'll enqueue a request ; StartDownloadAction + // knows to use the correct type of network according to the current settings. + + // Also note that once it's auto-installed, a word list will be marked as INSTALLED. It will + // thus receive automatic updates if there are any, which is what we want. If the user does + // not want this word list, they will have to go to the settings and change them, which will + // change the shared preferences. So there is no way for a word list that has been + // auto-installed once to get auto-installed again, and that's what we want. + final ActionBatch actions = new ActionBatch(); + WordListMetadata metadata = WordListMetadata.createFromContentValues(installCandidate); + actions.add(new ActionBatch.StartDownloadAction(clientId, metadata)); + final String localeString = installCandidate.getAsString(MetadataDbHelper.LOCALE_COLUMN); + + // We are in a content provider: we can't do any UI at all. We have to defer the displaying + // itself to the service. Also, we only display this when the user does not have a + // dictionary for this language already. During setup wizard, however, this UI is + // suppressed. + final boolean deviceProvisioned = Settings.Global.getInt(context.getContentResolver(), + Settings.Global.DEVICE_PROVISIONED, 0) != 0; + if (deviceProvisioned) { + final Intent intent = new Intent(); + intent.setClass(context, DictionaryService.class); + intent.setAction(DictionaryService.SHOW_DOWNLOAD_TOAST_INTENT_ACTION); + intent.putExtra(DictionaryService.LOCALE_INTENT_ARGUMENT, localeString); + context.startService(intent); + } else { + Log.i(TAG, "installIfNeverRequested() : Don't show download toast"); + } + + Log.i(TAG, "installIfNeverRequested() : StartDownloadAction for " + metadata); + actions.execute(context, new LogProblemReporter(TAG)); + } + + /** + * Marks the word list with the passed id as used. + * + * This will download/install the list as required. The action will see that the destination + * word list is a valid list, and take appropriate action - in this case, mark it as used. + * @see ActionBatch.Action#execute + * + * @param context the context for using action batches. + * @param clientId the id of the client. + * @param wordlistId the id of the word list to mark as installed. + * @param version the version of the word list to mark as installed. + * @param status the current status of the word list. + * @param allowDownloadOnMeteredData whether to download even on metered data connection + */ + // The version argument is not used yet, because we don't need it to retrieve the information + // we need. However, the pair (id, version) being the primary key to a word list in the database + // it feels better for consistency to pass it, and some methods retrieving information about a + // word list need it so we may need it in the future. + public static void markAsUsed(final Context context, final String clientId, + final String wordlistId, final int version, + final int status, final boolean allowDownloadOnMeteredData) { + final WordListMetadata wordListMetaData = MetadataHandler.getCurrentMetadataForWordList( + context, clientId, wordlistId, version); + + if (null == wordListMetaData) return; + + final ActionBatch actions = new ActionBatch(); + if (MetadataDbHelper.STATUS_DISABLED == status + || MetadataDbHelper.STATUS_DELETING == status) { + actions.add(new ActionBatch.EnableAction(clientId, wordListMetaData)); + } else if (MetadataDbHelper.STATUS_AVAILABLE == status) { + actions.add(new ActionBatch.StartDownloadAction(clientId, wordListMetaData)); + } else { + Log.e(TAG, "Unexpected state of the word list for markAsUsed : " + status); + } + actions.execute(context, new LogProblemReporter(TAG)); + signalNewDictionaryState(context); + } + + /** + * Marks the word list with the passed id as unused. + * + * This leaves the file on the disk for ulterior use. The action will see that the destination + * word list is null, and take appropriate action - in this case, mark it as unused. + * @see ActionBatch.Action#execute + * + * @param context the context for using action batches. + * @param clientId the id of the client. + * @param wordlistId the id of the word list to mark as installed. + * @param version the version of the word list to mark as installed. + * @param status the current status of the word list. + */ + // The version and status arguments are not used yet, but this method matches its interface to + // markAsUsed for consistency. + public static void markAsUnused(final Context context, final String clientId, + final String wordlistId, final int version, final int status) { + + final WordListMetadata wordListMetaData = MetadataHandler.getCurrentMetadataForWordList( + context, clientId, wordlistId, version); + + if (null == wordListMetaData) return; + final ActionBatch actions = new ActionBatch(); + actions.add(new ActionBatch.DisableAction(clientId, wordListMetaData)); + actions.execute(context, new LogProblemReporter(TAG)); + signalNewDictionaryState(context); + } + + /** + * Marks the word list with the passed id as deleting. + * + * This basically means that on the next chance there is (right away if Kelar Keyboard + * happens to be up, or the next time it gets up otherwise) the dictionary pack will + * supply an empty dictionary to it that will replace whatever dictionary is installed. + * This allows to release the space taken by a dictionary (except for the few bytes the + * empty dictionary takes up), and override a built-in default dictionary so that we + * can fake delete a built-in dictionary. + * + * @param context the context to open the database on. + * @param clientId the id of the client. + * @param wordlistId the id of the word list to mark as deleted. + * @param version the version of the word list to mark as deleted. + * @param status the current status of the word list. + */ + public static void markAsDeleting(final Context context, final String clientId, + final String wordlistId, final int version, final int status) { + + final WordListMetadata wordListMetaData = MetadataHandler.getCurrentMetadataForWordList( + context, clientId, wordlistId, version); + + if (null == wordListMetaData) return; + final ActionBatch actions = new ActionBatch(); + actions.add(new ActionBatch.DisableAction(clientId, wordListMetaData)); + actions.add(new ActionBatch.StartDeleteAction(clientId, wordListMetaData)); + actions.execute(context, new LogProblemReporter(TAG)); + signalNewDictionaryState(context); + } + + /** + * Marks the word list with the passed id as actually deleted. + * + * This reverts to available status or deletes the row as appropriate. + * + * @param context the context to open the database on. + * @param clientId the id of the client. + * @param wordlistId the id of the word list to mark as deleted. + * @param version the version of the word list to mark as deleted. + * @param status the current status of the word list. + */ + public static void markAsDeleted(final Context context, final String clientId, + final String wordlistId, final int version, final int status) { + final WordListMetadata wordListMetaData = MetadataHandler.getCurrentMetadataForWordList( + context, clientId, wordlistId, version); + + if (null == wordListMetaData) return; + + final ActionBatch actions = new ActionBatch(); + actions.add(new ActionBatch.FinishDeleteAction(clientId, wordListMetaData)); + actions.execute(context, new LogProblemReporter(TAG)); + signalNewDictionaryState(context); + } + + /** + * Checks whether the word list should be downloaded again; in which case an download & + * installation attempt is made. Otherwise the word list is marked broken. + * + * @param context the context to open the database on. + * @param clientId the id of the client. + * @param wordlistId the id of the word list which is broken. + * @param version the version of the broken word list. + */ + public static void markAsBrokenOrRetrying(final Context context, final String clientId, + final String wordlistId, final int version) { + boolean isRetryPossible = MetadataDbHelper.maybeMarkEntryAsRetrying( + MetadataDbHelper.getDb(context, clientId), wordlistId, version); + + if (isRetryPossible) { + if (DEBUG) { + Log.d(TAG, "Attempting to download & install the wordlist again."); + } + final WordListMetadata wordListMetaData = MetadataHandler.getCurrentMetadataForWordList( + context, clientId, wordlistId, version); + if (wordListMetaData == null) { + return; + } + + final ActionBatch actions = new ActionBatch(); + actions.add(new ActionBatch.StartDownloadAction(clientId, wordListMetaData)); + actions.execute(context, new LogProblemReporter(TAG)); + } else { + if (DEBUG) { + Log.d(TAG, "Retries for wordlist exhausted, deleting the wordlist from table."); + } + MetadataDbHelper.deleteEntry(MetadataDbHelper.getDb(context, clientId), + wordlistId, version); + } + } +} diff --git a/java/src/org/kelar/inputmethod/dictionarypack/WordListMetadata.java b/java/src/org/kelar/inputmethod/dictionarypack/WordListMetadata.java new file mode 100644 index 000000000..276077a80 --- /dev/null +++ b/java/src/org/kelar/inputmethod/dictionarypack/WordListMetadata.java @@ -0,0 +1,135 @@ +/* + * 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.ContentValues; + +import javax.annotation.Nonnull; + +/** + * The metadata for a single word list. + * + * Instances of this class are always immutable. + */ +public class WordListMetadata { + + public final String mId; + public final int mType; // Type, as of MetadataDbHelper#TYPE_* + public final String mDescription; + public final long mLastUpdate; + public final long mFileSize; + public final String mRawChecksum; + public final String mChecksum; + public final String mLocalFilename; + public final String mRemoteFilename; + public final int mVersion; // version of this word list + public final int mFlags; // Always 0 in this version, reserved for future use + public int mRetryCount; + + // The locale is matched against the locale requested by the client. The matching algorithm + // is a standard locale matching with fallback; it is implemented in + // DictionaryProvider#getDictionaryFileForContentUri. + public final String mLocale; + + + // Version number of the format. + // This implementation of the DictionaryDataService knows how to handle format 1 only. + // This is only for forward compatibility, to be able to upgrade the format without + // breaking old implementations. + public final int mFormatVersion; + + public WordListMetadata(final String id, final int type, + final String description, final long lastUpdate, final long fileSize, + final String rawChecksum, final String checksum, final int retryCount, + final String localFilename, final String remoteFilename, + final int version, final int formatVersion, + final int flags, final String locale) { + mId = id; + mType = type; + mDescription = description; + mLastUpdate = lastUpdate; // In milliseconds + mFileSize = fileSize; + mRawChecksum = rawChecksum; + mChecksum = checksum; + mRetryCount = retryCount; + mLocalFilename = localFilename; + mRemoteFilename = remoteFilename; + mVersion = version; + mFormatVersion = formatVersion; + mFlags = flags; + mLocale = locale; + } + + /** + * Create a WordListMetadata from the contents of a ContentValues. + * + * If this lacks any required field, IllegalArgumentException is thrown. + */ + public static WordListMetadata createFromContentValues(@Nonnull final ContentValues values) { + final String id = values.getAsString(MetadataDbHelper.WORDLISTID_COLUMN); + final Integer type = values.getAsInteger(MetadataDbHelper.TYPE_COLUMN); + final String description = values.getAsString(MetadataDbHelper.DESCRIPTION_COLUMN); + final Long lastUpdate = values.getAsLong(MetadataDbHelper.DATE_COLUMN); + final Long fileSize = values.getAsLong(MetadataDbHelper.FILESIZE_COLUMN); + final String rawChecksum = values.getAsString(MetadataDbHelper.RAW_CHECKSUM_COLUMN); + final String checksum = values.getAsString(MetadataDbHelper.CHECKSUM_COLUMN); + final int retryCount = values.getAsInteger(MetadataDbHelper.RETRY_COUNT_COLUMN); + final String localFilename = values.getAsString(MetadataDbHelper.LOCAL_FILENAME_COLUMN); + final String remoteFilename = values.getAsString(MetadataDbHelper.REMOTE_FILENAME_COLUMN); + final Integer version = values.getAsInteger(MetadataDbHelper.VERSION_COLUMN); + final Integer formatVersion = values.getAsInteger(MetadataDbHelper.FORMATVERSION_COLUMN); + final Integer flags = values.getAsInteger(MetadataDbHelper.FLAGS_COLUMN); + final String locale = values.getAsString(MetadataDbHelper.LOCALE_COLUMN); + if (null == id + || null == type + || null == description + || null == lastUpdate + || null == fileSize + || null == checksum + || null == localFilename + || null == remoteFilename + || null == version + || null == formatVersion + || null == flags + || null == locale) { + throw new IllegalArgumentException(); + } + return new WordListMetadata(id, type, description, lastUpdate, fileSize, rawChecksum, + checksum, retryCount, localFilename, remoteFilename, version, formatVersion, + flags, locale); + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder(WordListMetadata.class.getSimpleName()); + sb.append(" : ").append(mId); + sb.append("\nType : ").append(mType); + sb.append("\nDescription : ").append(mDescription); + sb.append("\nLastUpdate : ").append(mLastUpdate); + sb.append("\nFileSize : ").append(mFileSize); + sb.append("\nRawChecksum : ").append(mRawChecksum); + sb.append("\nChecksum : ").append(mChecksum); + sb.append("\nRetryCount: ").append(mRetryCount); + sb.append("\nLocalFilename : ").append(mLocalFilename); + sb.append("\nRemoteFilename : ").append(mRemoteFilename); + sb.append("\nVersion : ").append(mVersion); + sb.append("\nFormatVersion : ").append(mFormatVersion); + sb.append("\nFlags : ").append(mFlags); + sb.append("\nLocale : ").append(mLocale); + return sb.toString(); + } +} diff --git a/java/src/org/kelar/inputmethod/dictionarypack/WordListPreference.java b/java/src/org/kelar/inputmethod/dictionarypack/WordListPreference.java new file mode 100644 index 000000000..8c8a3fd99 --- /dev/null +++ b/java/src/org/kelar/inputmethod/dictionarypack/WordListPreference.java @@ -0,0 +1,310 @@ +/** + * 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.Context; +import android.content.SharedPreferences; +import android.preference.Preference; +import android.util.Log; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewParent; +import android.widget.ListView; +import android.widget.TextView; + +import org.kelar.inputmethod.latin.R; + +import java.util.Locale; + +/** + * A preference for one word list. + * + * This preference refers to a single word list, as available in the dictionary + * pack. Upon being pressed, it displays a menu to allow the user to install, disable, + * enable or delete it as appropriate for the current state of the word list. + */ +public final class WordListPreference extends Preference { + private static final String TAG = WordListPreference.class.getSimpleName(); + + // What to display in the "status" field when we receive unknown data as a status from + // the content provider. Empty string sounds sensible. + private static final String NO_STATUS_MESSAGE = ""; + + /// Actions + private static final int ACTION_UNKNOWN = 0; + private static final int ACTION_ENABLE_DICT = 1; + private static final int ACTION_DISABLE_DICT = 2; + private static final int ACTION_DELETE_DICT = 3; + + // Members + // The metadata word list id and version of this word list. + public final String mWordlistId; + public final int mVersion; + public final Locale mLocale; + public final String mDescription; + + // The id of the client for which this preference is. + private final String mClientId; + // The status + private int mStatus; + // The size of the dictionary file + private final int mFilesize; + + private final DictionaryListInterfaceState mInterfaceState; + + public WordListPreference(final Context context, + final DictionaryListInterfaceState dictionaryListInterfaceState, final String clientId, + final String wordlistId, final int version, final Locale locale, + final String description, final int status, final int filesize) { + super(context, null); + mInterfaceState = dictionaryListInterfaceState; + mClientId = clientId; + mVersion = version; + mWordlistId = wordlistId; + mFilesize = filesize; + mLocale = locale; + mDescription = description; + + setLayoutResource(R.layout.dictionary_line); + + setTitle(description); + setStatus(status); + setKey(wordlistId); + } + + public void setStatus(final int status) { + if (status == mStatus) return; + mStatus = status; + setSummary(getSummary(status)); + } + + public boolean hasStatus(final int status) { + return status == mStatus; + } + + @Override + public View onCreateView(final ViewGroup parent) { + final View orphanedView = mInterfaceState.findFirstOrphanedView(); + if (null != orphanedView) return orphanedView; // Will be sent to onBindView + final View newView = super.onCreateView(parent); + return mInterfaceState.addToCacheAndReturnView(newView); + } + + public boolean hasPriorityOver(final int otherPrefStatus) { + // Both of these should be one of MetadataDbHelper.STATUS_* + return mStatus > otherPrefStatus; + } + + private String getSummary(final int status) { + final Context context = getContext(); + switch (status) { + // If we are deleting the word list, for the user it's like it's already deleted. + // It should be reinstallable. Exposing to the user the whole complexity of + // the delayed deletion process between the dictionary pack and Kelar Keyboard + // would only be confusing. + case MetadataDbHelper.STATUS_DELETING: + case MetadataDbHelper.STATUS_AVAILABLE: + return context.getString(R.string.dictionary_available); + case MetadataDbHelper.STATUS_DOWNLOADING: + return context.getString(R.string.dictionary_downloading); + case MetadataDbHelper.STATUS_INSTALLED: + return context.getString(R.string.dictionary_installed); + case MetadataDbHelper.STATUS_DISABLED: + return context.getString(R.string.dictionary_disabled); + default: + return NO_STATUS_MESSAGE; + } + } + + // The table below needs to be kept in sync with MetadataDbHelper.STATUS_* since it uses + // the values as indices. + private static final int sStatusActionList[][] = { + // MetadataDbHelper.STATUS_UNKNOWN + {}, + // MetadataDbHelper.STATUS_AVAILABLE + { ButtonSwitcher.STATUS_INSTALL, ACTION_ENABLE_DICT }, + // MetadataDbHelper.STATUS_DOWNLOADING + { ButtonSwitcher.STATUS_CANCEL, ACTION_DISABLE_DICT }, + // MetadataDbHelper.STATUS_INSTALLED + { ButtonSwitcher.STATUS_DELETE, ACTION_DELETE_DICT }, + // MetadataDbHelper.STATUS_DISABLED + { ButtonSwitcher.STATUS_DELETE, ACTION_DELETE_DICT }, + // MetadataDbHelper.STATUS_DELETING + // We show 'install' because the file is supposed to be deleted. + // The user may reinstall it. + { ButtonSwitcher.STATUS_INSTALL, ACTION_ENABLE_DICT } + }; + + static int getButtonSwitcherStatus(final int status) { + if (status >= sStatusActionList.length) { + Log.e(TAG, "Unknown status " + status); + return ButtonSwitcher.STATUS_NO_BUTTON; + } + return sStatusActionList[status][0]; + } + + static int getActionIdFromStatusAndMenuEntry(final int status) { + if (status >= sStatusActionList.length) { + Log.e(TAG, "Unknown status " + status); + return ACTION_UNKNOWN; + } + return sStatusActionList[status][1]; + } + + private void disableDict() { + final Context context = getContext(); + final SharedPreferences prefs = CommonPreferences.getCommonPreferences(context); + CommonPreferences.disable(prefs, mWordlistId); + UpdateHandler.markAsUnused(context, mClientId, mWordlistId, mVersion, mStatus); + if (MetadataDbHelper.STATUS_DOWNLOADING == mStatus) { + setStatus(MetadataDbHelper.STATUS_AVAILABLE); + } else if (MetadataDbHelper.STATUS_INSTALLED == mStatus) { + // Interface-wise, we should no longer be able to come here. However, this is still + // the right thing to do if we do come here. + setStatus(MetadataDbHelper.STATUS_DISABLED); + } else { + Log.e(TAG, "Unexpected state of the word list for disabling " + mStatus); + } + } + + private void enableDict() { + final Context context = getContext(); + final SharedPreferences prefs = CommonPreferences.getCommonPreferences(context); + CommonPreferences.enable(prefs, mWordlistId); + // Explicit enabling by the user : allow downloading on metered data connection. + UpdateHandler.markAsUsed(context, mClientId, mWordlistId, mVersion, mStatus, true); + if (MetadataDbHelper.STATUS_AVAILABLE == mStatus) { + setStatus(MetadataDbHelper.STATUS_DOWNLOADING); + } else if (MetadataDbHelper.STATUS_DISABLED == mStatus + || MetadataDbHelper.STATUS_DELETING == mStatus) { + // If the status is DELETING, it means Kelar Keyboard + // has not deleted the word list yet, so we can safely + // turn it to 'installed'. The status DISABLED is still supported internally to + // avoid breaking older installations and all but there should not be a way to + // disable a word list through the interface any more. + setStatus(MetadataDbHelper.STATUS_INSTALLED); + } else { + Log.e(TAG, "Unexpected state of the word list for enabling " + mStatus); + } + } + + private void deleteDict() { + final Context context = getContext(); + final SharedPreferences prefs = CommonPreferences.getCommonPreferences(context); + CommonPreferences.disable(prefs, mWordlistId); + setStatus(MetadataDbHelper.STATUS_DELETING); + UpdateHandler.markAsDeleting(context, mClientId, mWordlistId, mVersion, mStatus); + } + + @Override + protected void onBindView(final View view) { + super.onBindView(view); + ((ViewGroup)view).setLayoutTransition(null); + + final DictionaryDownloadProgressBar progressBar = + (DictionaryDownloadProgressBar)view.findViewById(R.id.dictionary_line_progress_bar); + final TextView status = (TextView)view.findViewById(android.R.id.summary); + progressBar.setIds(mClientId, mWordlistId); + progressBar.setMax(mFilesize); + final boolean showProgressBar = (MetadataDbHelper.STATUS_DOWNLOADING == mStatus); + setSummary(getSummary(mStatus)); + status.setVisibility(showProgressBar ? View.INVISIBLE : View.VISIBLE); + progressBar.setVisibility(showProgressBar ? View.VISIBLE : View.INVISIBLE); + + final ButtonSwitcher buttonSwitcher = (ButtonSwitcher)view.findViewById( + R.id.wordlist_button_switcher); + // We need to clear the state of the button switcher, because we reuse views; if we didn't + // reset it would animate from whatever its old state was. + buttonSwitcher.reset(mInterfaceState); + if (mInterfaceState.isOpen(mWordlistId)) { + // The button is open. + final int previousStatus = mInterfaceState.getStatus(mWordlistId); + buttonSwitcher.setStatusAndUpdateVisuals(getButtonSwitcherStatus(previousStatus)); + if (previousStatus != mStatus) { + // We come here if the status has changed since last time. We need to animate + // the transition. + buttonSwitcher.setStatusAndUpdateVisuals(getButtonSwitcherStatus(mStatus)); + mInterfaceState.setOpen(mWordlistId, mStatus); + } + } else { + // The button is closed. + buttonSwitcher.setStatusAndUpdateVisuals(ButtonSwitcher.STATUS_NO_BUTTON); + } + buttonSwitcher.setInternalOnClickListener(new View.OnClickListener() { + @Override + public void onClick(final View v) { + onActionButtonClicked(); + } + }); + view.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(final View v) { + onWordListClicked(v); + } + }); + } + + void onWordListClicked(final View v) { + // Note : v is the preference view + final ViewParent parent = v.getParent(); + // Just in case something changed in the framework, test for the concrete class + if (!(parent instanceof ListView)) return; + final ListView listView = (ListView)parent; + final int indexToOpen; + // Close all first, we'll open back any item that needs to be open. + final boolean wasOpen = mInterfaceState.isOpen(mWordlistId); + mInterfaceState.closeAll(); + if (wasOpen) { + // This button being shown. Take note that we don't want to open any button in the + // loop below. + indexToOpen = -1; + } else { + // This button was not being shown. Open it, and remember the index of this + // child as the one to open in the following loop. + mInterfaceState.setOpen(mWordlistId, mStatus); + indexToOpen = listView.indexOfChild(v); + } + final int lastDisplayedIndex = + listView.getLastVisiblePosition() - listView.getFirstVisiblePosition(); + // The "lastDisplayedIndex" is actually displayed, hence the <= + for (int i = 0; i <= lastDisplayedIndex; ++i) { + final ButtonSwitcher buttonSwitcher = (ButtonSwitcher)listView.getChildAt(i) + .findViewById(R.id.wordlist_button_switcher); + if (i == indexToOpen) { + buttonSwitcher.setStatusAndUpdateVisuals(getButtonSwitcherStatus(mStatus)); + } else { + buttonSwitcher.setStatusAndUpdateVisuals(ButtonSwitcher.STATUS_NO_BUTTON); + } + } + } + + void onActionButtonClicked() { + switch (getActionIdFromStatusAndMenuEntry(mStatus)) { + case ACTION_ENABLE_DICT: + enableDict(); + break; + case ACTION_DISABLE_DICT: + disableDict(); + break; + case ACTION_DELETE_DICT: + deleteDict(); + break; + default: + Log.e(TAG, "Unknown menu item pressed"); + } + } +} |