aboutsummaryrefslogtreecommitdiffstats
path: root/java/src/org/kelar/inputmethod/dictionarypack
diff options
context:
space:
mode:
Diffstat (limited to 'java/src/org/kelar/inputmethod/dictionarypack')
-rw-r--r--java/src/org/kelar/inputmethod/dictionarypack/ActionBatch.java625
-rw-r--r--java/src/org/kelar/inputmethod/dictionarypack/AssetFileAddress.java66
-rw-r--r--java/src/org/kelar/inputmethod/dictionarypack/BadFormatException.java30
-rw-r--r--java/src/org/kelar/inputmethod/dictionarypack/ButtonSwitcher.java170
-rw-r--r--java/src/org/kelar/inputmethod/dictionarypack/CommonPreferences.java40
-rw-r--r--java/src/org/kelar/inputmethod/dictionarypack/CompletedDownloadInfo.java36
-rw-r--r--java/src/org/kelar/inputmethod/dictionarypack/DictionaryDownloadProgressBar.java173
-rw-r--r--java/src/org/kelar/inputmethod/dictionarypack/DictionaryListInterfaceState.java85
-rw-r--r--java/src/org/kelar/inputmethod/dictionarypack/DictionaryPackConstants.java72
-rw-r--r--java/src/org/kelar/inputmethod/dictionarypack/DictionaryProvider.java541
-rw-r--r--java/src/org/kelar/inputmethod/dictionarypack/DictionaryService.java280
-rw-r--r--java/src/org/kelar/inputmethod/dictionarypack/DictionarySettingsActivity.java54
-rw-r--r--java/src/org/kelar/inputmethod/dictionarypack/DictionarySettingsFragment.java438
-rw-r--r--java/src/org/kelar/inputmethod/dictionarypack/DownloadIdAndStartDate.java29
-rw-r--r--java/src/org/kelar/inputmethod/dictionarypack/DownloadManagerWrapper.java112
-rw-r--r--java/src/org/kelar/inputmethod/dictionarypack/DownloadOverMeteredDialog.java86
-rw-r--r--java/src/org/kelar/inputmethod/dictionarypack/DownloadRecord.java37
-rw-r--r--java/src/org/kelar/inputmethod/dictionarypack/EventHandler.java46
-rw-r--r--java/src/org/kelar/inputmethod/dictionarypack/LogProblemReporter.java35
-rw-r--r--java/src/org/kelar/inputmethod/dictionarypack/MD5Calculator.java46
-rw-r--r--java/src/org/kelar/inputmethod/dictionarypack/MetadataDbHelper.java1155
-rw-r--r--java/src/org/kelar/inputmethod/dictionarypack/MetadataHandler.java173
-rw-r--r--java/src/org/kelar/inputmethod/dictionarypack/MetadataParser.java114
-rw-r--r--java/src/org/kelar/inputmethod/dictionarypack/MetadataUriGetter.java29
-rw-r--r--java/src/org/kelar/inputmethod/dictionarypack/PrivateLog.java102
-rw-r--r--java/src/org/kelar/inputmethod/dictionarypack/ProblemReporter.java24
-rw-r--r--java/src/org/kelar/inputmethod/dictionarypack/UpdateHandler.java1082
-rw-r--r--java/src/org/kelar/inputmethod/dictionarypack/WordListMetadata.java135
-rw-r--r--java/src/org/kelar/inputmethod/dictionarypack/WordListPreference.java310
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");
+ }
+ }
+}