aboutsummaryrefslogtreecommitdiffstats
path: root/java/src
diff options
context:
space:
mode:
Diffstat (limited to 'java/src')
-rw-r--r--java/src/com/android/inputmethod/compat/ConnectivityManagerCompatUtils.java36
-rw-r--r--java/src/com/android/inputmethod/compat/DownloadManagerCompatUtils.java38
-rw-r--r--java/src/com/android/inputmethod/dictionarypack/ActionBatch.java641
-rw-r--r--java/src/com/android/inputmethod/dictionarypack/AssetFileAddress.java66
-rw-r--r--java/src/com/android/inputmethod/dictionarypack/BadFormatException.java30
-rw-r--r--java/src/com/android/inputmethod/dictionarypack/CommonPreferences.java40
-rw-r--r--java/src/com/android/inputmethod/dictionarypack/CompletedDownloadInfo.java36
-rw-r--r--java/src/com/android/inputmethod/dictionarypack/DictionaryProvider.java533
-rw-r--r--java/src/com/android/inputmethod/dictionarypack/DictionaryService.java242
-rw-r--r--java/src/com/android/inputmethod/dictionarypack/DictionarySettingsActivity.java42
-rw-r--r--java/src/com/android/inputmethod/dictionarypack/DictionarySettingsFragment.java365
-rw-r--r--java/src/com/android/inputmethod/dictionarypack/DownloadOverMeteredDialog.java77
-rw-r--r--java/src/com/android/inputmethod/dictionarypack/DownloadRecord.java37
-rw-r--r--java/src/com/android/inputmethod/dictionarypack/EventHandler.java52
-rw-r--r--java/src/com/android/inputmethod/dictionarypack/LocaleUtils.java204
-rw-r--r--java/src/com/android/inputmethod/dictionarypack/LogProblemReporter.java34
-rw-r--r--java/src/com/android/inputmethod/dictionarypack/MD5Calculator.java46
-rw-r--r--java/src/com/android/inputmethod/dictionarypack/MetadataDbHelper.java978
-rw-r--r--java/src/com/android/inputmethod/dictionarypack/MetadataHandler.java141
-rw-r--r--java/src/com/android/inputmethod/dictionarypack/MetadataParser.java111
-rw-r--r--java/src/com/android/inputmethod/dictionarypack/PrivateLog.java109
-rw-r--r--java/src/com/android/inputmethod/dictionarypack/ProblemReporter.java24
-rw-r--r--java/src/com/android/inputmethod/dictionarypack/UpdateHandler.java1088
-rw-r--r--java/src/com/android/inputmethod/dictionarypack/Utils.java104
-rw-r--r--java/src/com/android/inputmethod/dictionarypack/WordListMetadata.java122
-rw-r--r--java/src/com/android/inputmethod/dictionarypack/WordListPreference.java248
-rw-r--r--java/src/com/android/inputmethod/latin/BinaryDictionary.java4
-rw-r--r--java/src/com/android/inputmethod/latin/BinaryDictionaryGetter.java14
-rw-r--r--java/src/com/android/inputmethod/latin/DictionaryPackInstallBroadcastReceiver.java4
-rw-r--r--java/src/com/android/inputmethod/latin/SettingsFragment.java2
-rw-r--r--java/src/com/android/inputmethod/latin/spellcheck/AndroidSpellCheckerSession.java28
-rw-r--r--java/src/com/android/inputmethod/latin/spellcheck/AndroidWordLevelSpellCheckerSession.java26
32 files changed, 5492 insertions, 30 deletions
diff --git a/java/src/com/android/inputmethod/compat/ConnectivityManagerCompatUtils.java b/java/src/com/android/inputmethod/compat/ConnectivityManagerCompatUtils.java
new file mode 100644
index 000000000..b561f7a14
--- /dev/null
+++ b/java/src/com/android/inputmethod/compat/ConnectivityManagerCompatUtils.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 com.android.inputmethod.compat;
+
+import android.net.ConnectivityManager;
+
+import java.lang.reflect.Method;
+
+public final class ConnectivityManagerCompatUtils {
+ // ConnectivityManager#isActiveNetworkMetered() has been introduced
+ // in API level 16 (Build.VERSION_CODES.JELLY_BEAN).
+ private static final Method METHOD_isActiveNetworkMetered = CompatUtils.getMethod(
+ ConnectivityManager.class, "isActiveNetworkMetered");
+
+ public static boolean isActiveNetworkMetered(final ConnectivityManager manager) {
+ return (Boolean)CompatUtils.invoke(manager,
+ // If the API telling whether the network is metered or not is not available,
+ // then the closest thing is "if it's a mobile connection".
+ manager.getActiveNetworkInfo().getType() == ConnectivityManager.TYPE_MOBILE,
+ METHOD_isActiveNetworkMetered);
+ }
+}
diff --git a/java/src/com/android/inputmethod/compat/DownloadManagerCompatUtils.java b/java/src/com/android/inputmethod/compat/DownloadManagerCompatUtils.java
new file mode 100644
index 000000000..d0b9c5da6
--- /dev/null
+++ b/java/src/com/android/inputmethod/compat/DownloadManagerCompatUtils.java
@@ -0,0 +1,38 @@
+/*
+ * 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 com.android.inputmethod.compat;
+
+import android.app.DownloadManager;
+
+import java.lang.reflect.Method;
+
+public final class DownloadManagerCompatUtils {
+ // DownloadManager.Request#setAllowedOverMetered() has been introduced
+ // in API level 16 (Build.VERSION_CODES.JELLY_BEAN).
+ private static final Method METHOD_setAllowedOverMetered = CompatUtils.getMethod(
+ DownloadManager.Request.class, "setAllowedOverMetered", Boolean.TYPE);
+
+ public static DownloadManager.Request setAllowedOverMetered(
+ final DownloadManager.Request request, final boolean allowOverMetered) {
+ return (DownloadManager.Request)CompatUtils.invoke(request,
+ request /* default return value */, METHOD_setAllowedOverMetered, allowOverMetered);
+ }
+
+ public static final boolean hasSetAllowedOverMetered() {
+ return null != METHOD_setAllowedOverMetered;
+ }
+}
diff --git a/java/src/com/android/inputmethod/dictionarypack/ActionBatch.java b/java/src/com/android/inputmethod/dictionarypack/ActionBatch.java
new file mode 100644
index 000000000..df4a52f4e
--- /dev/null
+++ b/java/src/com/android/inputmethod/dictionarypack/ActionBatch.java
@@ -0,0 +1,641 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.android.inputmethod.dictionarypack;
+
+import android.app.DownloadManager;
+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 com.android.inputmethod.compat.DownloadManagerCompatUtils;
+import com.android.inputmethod.latin.R;
+
+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
+ */
+ public 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;
+ final boolean mForceStartNow;
+ public StartDownloadAction(final String clientId,
+ final WordListMetadata wordList, final boolean forceStartNow) {
+ Utils.l("New download action for client ", clientId, " : ", wordList);
+ mClientId = clientId;
+ mWordList = wordList;
+ mForceStartNow = forceStartNow;
+ }
+
+ @Override
+ public void execute(final Context context) {
+ if (null == mWordList) { // This should never happen
+ Log.e(TAG, "UpdateAction with a null parameter!");
+ return;
+ }
+ Utils.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 DownloadManager manager =
+ (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE);
+ if (MetadataDbHelper.STATUS_DOWNLOADING == status) {
+ // The word list is still downloading. Cancel the download and revert the
+ // word list status to "available".
+ if (null != manager) {
+ // DownloadManager is disabled (or not installed?). We can't cancel - there
+ // is nothing we can do. We still need to mark the entry as available.
+ manager.remove(values.getAsLong(MetadataDbHelper.PENDINGID_COLUMN));
+ }
+ MetadataDbHelper.markEntryAsAvailable(db, mWordList.mId, mWordList.mVersion);
+ } else if (MetadataDbHelper.STATUS_AVAILABLE != 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.
+ Utils.l("Upgrade word list, downloading", mWordList.mRemoteFilename);
+
+ // TODO: if DownloadManager is disabled or not installed, download by ourselves
+ if (null == manager) return;
+
+ // This is an upgraded word list: we should download it.
+ final Uri uri = Uri.parse(mWordList.mRemoteFilename);
+ final Request request = new Request(uri);
+
+ final Resources res = context.getResources();
+ if (!mForceStartNow) {
+ if (DownloadManagerCompatUtils.hasSetAllowedOverMetered()) {
+ final boolean allowOverMetered;
+ switch (UpdateHandler.getDownloadOverMeteredSetting(context)) {
+ case UpdateHandler.DOWNLOAD_OVER_METERED_DISALLOWED:
+ // User said no: don't allow.
+ allowOverMetered = false;
+ break;
+ case UpdateHandler.DOWNLOAD_OVER_METERED_ALLOWED:
+ // User said yes: allow.
+ allowOverMetered = true;
+ break;
+ default: // UpdateHandler.DOWNLOAD_OVER_METERED_SETTING_UNKNOWN
+ // Don't know: use the default value from configuration.
+ allowOverMetered = res.getBoolean(R.bool.allow_over_metered);
+ }
+ DownloadManagerCompatUtils.setAllowedOverMetered(request, allowOverMetered);
+ } else {
+ request.setAllowedNetworkTypes(Request.NETWORK_WIFI);
+ }
+ request.setAllowedOverRoaming(res.getBoolean(R.bool.allow_over_roaming));
+ } // if mForceStartNow, then allow all network types and roaming, which is the default.
+ request.setTitle(mWordList.mDescription);
+ request.setNotificationVisibility(
+ res.getBoolean(R.bool.display_notification_for_auto_update)
+ ? Request.VISIBILITY_VISIBLE : 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);
+ Utils.l("Starting download of", uri, "with id", downloadId);
+ PrivateLog.log("Starting download of " + uri + ", id : " + downloadId, context);
+ }
+ }
+
+ /**
+ * 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) {
+ Utils.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;
+ }
+ Utils.l("Setting word list as installed");
+ final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId);
+ MetadataDbHelper.markEntryAsFinishedDownloadingAndInstalled(db, mWordListValues);
+ }
+ }
+
+ /**
+ * 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) {
+ Utils.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;
+ }
+ Utils.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) {
+ Utils.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;
+ }
+ Utils.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 DownloadManager manager =
+ (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE);
+ if (null != manager) {
+ // If we can't cancel the download because DownloadManager is not available,
+ // we still need to mark the entry as available.
+ 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) {
+ Utils.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.");
+ }
+ Utils.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.mChecksum,
+ mWordList.mFileSize, mWordList.mVersion, mWordList.mFormatVersion);
+ PrivateLog.log("Insert 'available' record for " + mWordList.mDescription
+ + " and locale " + mWordList.mLocale, context);
+ 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) {
+ Utils.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.");
+ }
+ Utils.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,
+ "", mWordList.mRemoteFilename, mWordList.mLastUpdate,
+ mWordList.mChecksum, mWordList.mFileSize, mWordList.mVersion,
+ mWordList.mFormatVersion);
+ PrivateLog.log("Insert 'preinstalled' record for " + mWordList.mDescription
+ + " and locale " + mWordList.mLocale, context);
+ 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) {
+ Utils.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;
+ }
+ Utils.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.mChecksum,
+ mWordList.mFileSize, mWordList.mVersion, mWordList.mFormatVersion);
+ PrivateLog.log("Updating record for " + mWordList.mDescription
+ + " and locale " + mWordList.mLocale, context);
+ 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) {
+ Utils.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;
+ }
+ Utils.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, then we cannot remove the entry lest the user
+ // lose the ability to delete the file or otherwise administrate it. We will thus
+ // leave it as is, but remove the URI from the database since it is not supposed to
+ // be accessible any more.
+ // If it is deleting and we don't have a new version, then we have to wait until
+ // Android Keyboard actually has deleted it before we can remove its metadata.
+ values.put(MetadataDbHelper.REMOTE_FILENAME_COLUMN, "");
+ 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 Android 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
+ * Android 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) {
+ Utils.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;
+ }
+ Utils.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) {
+ Utils.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;
+ }
+ Utils.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<Action>();
+ }
+
+ 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) {
+ Utils.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/com/android/inputmethod/dictionarypack/AssetFileAddress.java b/java/src/com/android/inputmethod/dictionarypack/AssetFileAddress.java
new file mode 100644
index 000000000..bebb59fc0
--- /dev/null
+++ b/java/src/com/android/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 com.android.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/com/android/inputmethod/dictionarypack/BadFormatException.java b/java/src/com/android/inputmethod/dictionarypack/BadFormatException.java
new file mode 100644
index 000000000..d3090ddb0
--- /dev/null
+++ b/java/src/com/android/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 com.android.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/com/android/inputmethod/dictionarypack/CommonPreferences.java b/java/src/com/android/inputmethod/dictionarypack/CommonPreferences.java
new file mode 100644
index 000000000..7c27e6d51
--- /dev/null
+++ b/java/src/com/android/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 com.android.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, Context.MODE_WORLD_READABLE);
+ }
+
+ 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/com/android/inputmethod/dictionarypack/CompletedDownloadInfo.java b/java/src/com/android/inputmethod/dictionarypack/CompletedDownloadInfo.java
new file mode 100644
index 000000000..ff198756e
--- /dev/null
+++ b/java/src/com/android/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 com.android.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/com/android/inputmethod/dictionarypack/DictionaryProvider.java b/java/src/com/android/inputmethod/dictionarypack/DictionaryProvider.java
new file mode 100644
index 000000000..2da871305
--- /dev/null
+++ b/java/src/com/android/inputmethod/dictionarypack/DictionaryProvider.java
@@ -0,0 +1,533 @@
+/**
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.android.inputmethod.dictionarypack;
+
+import android.content.ContentProvider;
+import android.content.ContentResolver;
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.UriMatcher;
+import android.content.res.AssetFileDescriptor;
+import android.database.AbstractCursor;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.net.Uri;
+import android.os.ParcelFileDescriptor;
+import android.text.TextUtils;
+import android.util.Log;
+
+import com.android.inputmethod.latin.R;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+
+/**
+ * Provider for dictionaries.
+ *
+ * This class is a ContentProvider exposing all available dictionary data as managed by
+ * the dictionary pack.
+ */
+public final class DictionaryProvider extends ContentProvider {
+ private static final String TAG = DictionaryProvider.class.getSimpleName();
+ public static final boolean DEBUG = false;
+
+ // Authority and URI matching for the ContentProvider protocol.
+ // TODO: find some way to factorize this string with the one in the resources
+ public static final String AUTHORITY = "com.android.inputmethod.dictionarypack.aosp";
+ public static final Uri CONTENT_URI =
+ Uri.parse(ContentResolver.SCHEME_CONTENT + "://" + AUTHORITY);
+ private static final String QUERY_PARAMETER_MAY_PROMPT_USER = "mayPrompt";
+ private static final String QUERY_PARAMETER_TRUE = "true";
+ private static final String QUERY_PARAMETER_DELETE_RESULT = "result";
+ private static final String QUERY_PARAMETER_SUCCESS = "success";
+ private static final String QUERY_PARAMETER_FAILURE = "failure";
+ public static final String QUERY_PARAMETER_PROTOCOL_VERSION = "protocol";
+ private static final int NO_MATCH = 0;
+ private static final int DICTIONARY_V1_WHOLE_LIST = 1;
+ private static final int DICTIONARY_V1_DICT_INFO = 2;
+ private static final int DICTIONARY_V2_METADATA = 3;
+ private static final int DICTIONARY_V2_WHOLE_LIST = 4;
+ private static final int DICTIONARY_V2_DICT_INFO = 5;
+ private static final int DICTIONARY_V2_DATAFILE = 6;
+ private static final UriMatcher sUriMatcherV1 = new UriMatcher(NO_MATCH);
+ private static final UriMatcher sUriMatcherV2 = new UriMatcher(NO_MATCH);
+ static
+ {
+ sUriMatcherV1.addURI(AUTHORITY, "list", DICTIONARY_V1_WHOLE_LIST);
+ sUriMatcherV1.addURI(AUTHORITY, "*", DICTIONARY_V1_DICT_INFO);
+ sUriMatcherV2.addURI(AUTHORITY, "*/metadata", DICTIONARY_V2_METADATA);
+ sUriMatcherV2.addURI(AUTHORITY, "*/list", DICTIONARY_V2_WHOLE_LIST);
+ sUriMatcherV2.addURI(AUTHORITY, "*/dict/*", DICTIONARY_V2_DICT_INFO);
+ sUriMatcherV2.addURI(AUTHORITY, "*/datafile/*", DICTIONARY_V2_DATAFILE);
+ }
+
+ // MIME types for dictionary and dictionary list, as required by ContentProvider contract.
+ public static final String DICT_LIST_MIME_TYPE =
+ "vnd.android.cursor.item/vnd.google.dictionarylist";
+ public static final String DICT_DATAFILE_MIME_TYPE =
+ "vnd.android.cursor.item/vnd.google.dictionary";
+
+ public static final String ID_CATEGORY_SEPARATOR = ":";
+
+ private static final class WordListInfo {
+ public final String mId;
+ public final String mLocale;
+ public final int mMatchLevel;
+ public WordListInfo(final String id, final String locale, final int matchLevel) {
+ mId = id;
+ mLocale = locale;
+ mMatchLevel = matchLevel;
+ }
+ }
+
+ /**
+ * A cursor for returning a list of file ids from a List of strings.
+ *
+ * This simulates only the necessary methods. It has no error handling to speak of,
+ * and does not support everything a database does, only a few select necessary methods.
+ */
+ private static final class ResourcePathCursor extends AbstractCursor {
+
+ // Column names for the cursor returned by this content provider.
+ static private final String[] columnNames = { "id", "locale" };
+
+ // The list of word lists served by this provider that match the client request.
+ final WordListInfo[] mWordLists;
+ // Note : the cursor also uses mPos, which is defined in AbstractCursor.
+
+ public ResourcePathCursor(final Collection<WordListInfo> wordLists) {
+ // Allocating a 0-size WordListInfo here allows the toArray() method
+ // to ensure we have a strongly-typed array. It's thrown out. That's
+ // what the documentation of #toArray says to do in order to get a
+ // new strongly typed array of the correct size.
+ mWordLists = wordLists.toArray(new WordListInfo[0]);
+ mPos = 0;
+ }
+
+ @Override
+ public String[] getColumnNames() {
+ return columnNames;
+ }
+
+ @Override
+ public int getCount() {
+ return mWordLists.length;
+ }
+
+ @Override public double getDouble(int column) { return 0; }
+ @Override public float getFloat(int column) { return 0; }
+ @Override public int getInt(int column) { return 0; }
+ @Override public short getShort(int column) { return 0; }
+ @Override public long getLong(int column) { return 0; }
+
+ @Override public String getString(final int column) {
+ switch (column) {
+ case 0: return mWordLists[mPos].mId;
+ case 1: return mWordLists[mPos].mLocale;
+ default : return null;
+ }
+ }
+
+ @Override
+ public boolean isNull(final int column) {
+ if (mPos >= mWordLists.length) return true;
+ return column != 0;
+ }
+ }
+
+ @Override
+ public boolean onCreate() {
+ return true;
+ }
+
+ private static int matchUri(final Uri uri) {
+ int protocolVersion = 1;
+ final String protocolVersionArg = uri.getQueryParameter(QUERY_PARAMETER_PROTOCOL_VERSION);
+ if ("2".equals(protocolVersionArg)) protocolVersion = 2;
+ switch (protocolVersion) {
+ case 1: return sUriMatcherV1.match(uri);
+ case 2: return sUriMatcherV2.match(uri);
+ default: return NO_MATCH;
+ }
+ }
+
+ private static String getClientId(final Uri uri) {
+ int protocolVersion = 1;
+ final String protocolVersionArg = uri.getQueryParameter(QUERY_PARAMETER_PROTOCOL_VERSION);
+ if ("2".equals(protocolVersionArg)) protocolVersion = 2;
+ switch (protocolVersion) {
+ case 1: return null; // In protocol 1, the client ID is always null.
+ case 2: return uri.getPathSegments().get(0);
+ default: return null;
+ }
+ }
+
+ /**
+ * Returns the MIME type of the content associated with an Uri
+ *
+ * @see android.content.ContentProvider#getType(android.net.Uri)
+ *
+ * @param uri the URI of the content the type of which should be returned.
+ * @return the MIME type, or null if the URL is not recognized.
+ */
+ @Override
+ public String getType(final Uri uri) {
+ PrivateLog.log("Asked for type of : " + uri, this);
+ final int match = matchUri(uri);
+ switch (match) {
+ case NO_MATCH: return null;
+ case DICTIONARY_V1_WHOLE_LIST:
+ case DICTIONARY_V1_DICT_INFO:
+ case DICTIONARY_V2_WHOLE_LIST:
+ case DICTIONARY_V2_DICT_INFO: return DICT_LIST_MIME_TYPE;
+ case DICTIONARY_V2_DATAFILE: return DICT_DATAFILE_MIME_TYPE;
+ default: return null;
+ }
+ }
+
+ /**
+ * Query the provider for dictionary files.
+ *
+ * This version dispatches the query according to the protocol version found in the
+ * ?protocol= query parameter. If absent or not well-formed, it defaults to 1.
+ * @see android.content.ContentProvider#query(Uri, String[], String, String[], String)
+ *
+ * @param uri a content uri (see sUriMatcherV{1,2} at the top of this file for format)
+ * @param projection ignored. All columns are always returned.
+ * @param selection ignored.
+ * @param selectionArgs ignored.
+ * @param sortOrder ignored. The results are always returned in no particular order.
+ * @return a cursor matching the uri, or null if the URI was not recognized.
+ */
+ @Override
+ public Cursor query(final Uri uri, final String[] projection, final String selection,
+ final String[] selectionArgs, final String sortOrder) {
+ Utils.l("Uri =", uri);
+ PrivateLog.log("Query : " + uri, this);
+ final String clientId = getClientId(uri);
+ final int match = matchUri(uri);
+ switch (match) {
+ case DICTIONARY_V1_WHOLE_LIST:
+ case DICTIONARY_V2_WHOLE_LIST:
+ final Cursor c = MetadataDbHelper.queryDictionaries(getContext(), clientId);
+ Utils.l("List of dictionaries with count", c.getCount());
+ PrivateLog.log("Returned a list of " + c.getCount() + " items", this);
+ return c;
+ case DICTIONARY_V2_DICT_INFO:
+ // In protocol version 2, we return null if the client is unknown. Otherwise
+ // we behave exactly like for protocol 1.
+ if (!MetadataDbHelper.isClientKnown(getContext(), clientId)) return null;
+ // Fall through
+ case DICTIONARY_V1_DICT_INFO:
+ final String locale = uri.getLastPathSegment();
+ // If LatinIME does not have a dictionary for this locale at all, it will
+ // send us true for this value. In this case, we may prompt the user for
+ // a decision about downloading a dictionary even over a metered connection.
+ final String mayPromptValue =
+ uri.getQueryParameter(QUERY_PARAMETER_MAY_PROMPT_USER);
+ final boolean mayPrompt = QUERY_PARAMETER_TRUE.equals(mayPromptValue);
+ final Collection<WordListInfo> dictFiles =
+ getDictionaryWordListsForLocale(clientId, locale, mayPrompt);
+ // TODO: pass clientId to the following function
+ DictionaryService.updateNowIfNotUpdatedInAVeryLongTime(getContext());
+ if (null != dictFiles && dictFiles.size() > 0) {
+ PrivateLog.log("Returned " + dictFiles.size() + " files", this);
+ return new ResourcePathCursor(dictFiles);
+ } else {
+ PrivateLog.log("No dictionary files for this URL", this);
+ return new ResourcePathCursor(Collections.<WordListInfo>emptyList());
+ }
+ // V2_METADATA and V2_DATAFILE are not supported for query()
+ default:
+ return null;
+ }
+ }
+
+ /**
+ * Helper method to get the wordlist metadata associated with a wordlist ID.
+ *
+ * @param clientId the ID of the client
+ * @param wordlistId the ID of the wordlist for which to get the metadata.
+ * @return the metadata for this wordlist ID, or null if none could be found.
+ */
+ private ContentValues getWordlistMetadataForWordlistId(final String clientId,
+ final String wordlistId) {
+ final Context context = getContext();
+ if (TextUtils.isEmpty(wordlistId)) return null;
+ final SQLiteDatabase db = MetadataDbHelper.getDb(context, clientId);
+ return MetadataDbHelper.getInstalledOrDeletingWordListContentValuesByWordListId(
+ db, wordlistId);
+ }
+
+ /**
+ * Opens an asset file for an URI.
+ *
+ * Called by {@link android.content.ContentResolver#openAssetFileDescriptor(Uri, String)} or
+ * {@link android.content.ContentResolver#openInputStream(Uri)} from a client requesting a
+ * dictionary.
+ * @see android.content.ContentProvider#openAssetFile(Uri, String)
+ *
+ * @param uri the URI the file is for.
+ * @param mode the mode to read the file. MUST be "r" for readonly.
+ * @return the descriptor, or null if the file is not found or if mode is not equals to "r".
+ */
+ @Override
+ public AssetFileDescriptor openAssetFile(final Uri uri, final String mode) {
+ if (null == mode || !"r".equals(mode)) return null;
+
+ final int match = matchUri(uri);
+ if (DICTIONARY_V1_DICT_INFO != match && DICTIONARY_V2_DATAFILE != match) {
+ // Unsupported URI for openAssetFile
+ Log.w(TAG, "Unsupported URI for openAssetFile : " + uri);
+ return null;
+ }
+ final String wordlistId = uri.getLastPathSegment();
+ final String clientId = getClientId(uri);
+ final ContentValues wordList = getWordlistMetadataForWordlistId(clientId, wordlistId);
+
+ if (null == wordList) return null;
+
+ try {
+ final int status = wordList.getAsInteger(MetadataDbHelper.STATUS_COLUMN);
+ if (MetadataDbHelper.STATUS_DELETING == status) {
+ // This will return an empty file (R.raw.empty points at an empty dictionary)
+ // This is how we "delete" the files. It allows Android Keyboard to fake deleting
+ // a default dictionary - which is actually in its assets and can't be really
+ // deleted.
+ final AssetFileDescriptor afd = getContext().getResources().openRawResourceFd(
+ R.raw.empty);
+ return afd;
+ } else {
+ final String localFilename =
+ wordList.getAsString(MetadataDbHelper.LOCAL_FILENAME_COLUMN);
+ final File f = getContext().getFileStreamPath(localFilename);
+ final ParcelFileDescriptor pfd =
+ ParcelFileDescriptor.open(f, ParcelFileDescriptor.MODE_READ_ONLY);
+ return new AssetFileDescriptor(pfd, 0, pfd.getStatSize());
+ }
+ } catch (FileNotFoundException e) {
+ // No file : fall through and return null
+ }
+ return null;
+ }
+
+ /**
+ * Reads the metadata and returns the collection of dictionaries for a given locale.
+ *
+ * Word list IDs are expected to be in the form category:manual_id. This method
+ * will select only one word list for each category: the one with the most specific
+ * locale matching the locale specified in the URI. The manual id serves only to
+ * distinguish a word list from another for the purpose of updating, and is arbitrary
+ * but may not contain a colon.
+ *
+ * @param clientId the ID of the client requesting the list
+ * @param locale the locale for which we want the list, as a String
+ * @param mayPrompt true if we are allowed to prompt the user for arbitration via notification
+ * @return a collection of ids. It is guaranteed to be non-null, but may be empty.
+ */
+ private Collection<WordListInfo> getDictionaryWordListsForLocale(final String clientId,
+ final String locale, final boolean mayPrompt) {
+ final Context context = getContext();
+ final Cursor results =
+ MetadataDbHelper.queryInstalledOrDeletingOrAvailableDictionaryMetadata(context,
+ clientId);
+ if (null == results) {
+ return Collections.<WordListInfo>emptyList();
+ } else {
+ final HashMap<String, WordListInfo> dicts = new HashMap<String, WordListInfo>();
+ final int idIndex = results.getColumnIndex(MetadataDbHelper.WORDLISTID_COLUMN);
+ final int localeIndex = results.getColumnIndex(MetadataDbHelper.LOCALE_COLUMN);
+ final int localFileNameIndex =
+ results.getColumnIndex(MetadataDbHelper.LOCAL_FILENAME_COLUMN);
+ final int statusIndex = results.getColumnIndex(MetadataDbHelper.STATUS_COLUMN);
+ if (results.moveToFirst()) {
+ do {
+ final String wordListId = results.getString(idIndex);
+ if (TextUtils.isEmpty(wordListId)) continue;
+ final String[] wordListIdArray =
+ TextUtils.split(wordListId, ID_CATEGORY_SEPARATOR);
+ final String wordListCategory;
+ if (2 == wordListIdArray.length) {
+ // This is at the category:manual_id format.
+ wordListCategory = wordListIdArray[0];
+ // We don't need to read wordListIdArray[1] here, because it's irrelevant to
+ // word list selection - it's just a name we use to identify which data file
+ // is a newer version of which word list. We do however return the full id
+ // string for each selected word list, so in this sense we are 'using' it.
+ } else {
+ // This does not contain a colon, like the old format does. Old-format IDs
+ // always point to main dictionaries, so we force the main category upon it.
+ wordListCategory = UpdateHandler.MAIN_DICTIONARY_CATEGORY;
+ }
+ final String wordListLocale = results.getString(localeIndex);
+ final String wordListLocalFilename = results.getString(localFileNameIndex);
+ final int wordListStatus = results.getInt(statusIndex);
+ // Test the requested locale against this wordlist locale. The requested locale
+ // has to either match exactly or be more specific than the dictionary - a
+ // dictionary for "en" would match both a request for "en" or for "en_US", but a
+ // dictionary for "en_GB" would not match a request for "en_US". Thus if all
+ // three of "en" "en_US" and "en_GB" dictionaries are installed, a request for
+ // "en_US" would match "en" and "en_US", and a request for "en" only would only
+ // match the generic "en" dictionary. For more details, see the documentation
+ // for LocaleUtils#getMatchLevel.
+ final int matchLevel = LocaleUtils.getMatchLevel(wordListLocale, locale);
+ if (!LocaleUtils.isMatch(matchLevel)) {
+ // The locale of this wordlist does not match the required locale.
+ // Skip this wordlist and go to the next.
+ continue;
+ }
+ if (MetadataDbHelper.STATUS_INSTALLED == wordListStatus) {
+ // If the file does not exist, it has been deleted and the IME should
+ // already have it. Do not return it. However, this only applies if the
+ // word list is INSTALLED, for if it is DELETING we should return it always
+ // so that Android Keyboard can perform the actual deletion.
+ final File f = getContext().getFileStreamPath(wordListLocalFilename);
+ if (!f.isFile()) {
+ continue;
+ }
+ } else if (MetadataDbHelper.STATUS_AVAILABLE == wordListStatus) {
+ // The locale is the id for the main dictionary.
+ UpdateHandler.installIfNeverRequested(context, clientId, wordListId,
+ mayPrompt);
+ continue;
+ }
+ final WordListInfo currentBestMatch = dicts.get(wordListCategory);
+ if (null == currentBestMatch
+ || currentBestMatch.mMatchLevel < matchLevel) {
+ dicts.put(wordListCategory,
+ new WordListInfo(wordListId, wordListLocale, matchLevel));
+ }
+ } while (results.moveToNext());
+ }
+ results.close();
+ return Collections.unmodifiableCollection(dicts.values());
+ }
+ }
+
+ /**
+ * Deletes the file pointed by Uri, as returned by openAssetFile.
+ *
+ * @param uri the URI the file is for.
+ * @param selection ignored
+ * @param selectionArgs ignored
+ * @return the number of files deleted (0 or 1 in the current implementation)
+ * @see android.content.ContentProvider#delete(Uri, String, String[])
+ */
+ @Override
+ public int delete(final Uri uri, final String selection, final String[] selectionArgs)
+ throws UnsupportedOperationException {
+ final int match = matchUri(uri);
+ if (DICTIONARY_V1_DICT_INFO == match || DICTIONARY_V2_DATAFILE == match) {
+ return deleteDataFile(uri);
+ }
+ if (DICTIONARY_V2_METADATA == match) {
+ if (MetadataDbHelper.deleteClient(getContext(), getClientId(uri))) {
+ return 1;
+ }
+ return 0;
+ }
+ // Unsupported URI for delete
+ return 0;
+ }
+
+ private int deleteDataFile(final Uri uri) {
+ final String wordlistId = uri.getLastPathSegment();
+ final String clientId = getClientId(uri);
+ final ContentValues wordList = getWordlistMetadataForWordlistId(clientId, wordlistId);
+ if (null == wordList) return 0;
+ final int status = wordList.getAsInteger(MetadataDbHelper.STATUS_COLUMN);
+ final int version = wordList.getAsInteger(MetadataDbHelper.VERSION_COLUMN);
+ if (MetadataDbHelper.STATUS_DELETING == status) {
+ UpdateHandler.markAsDeleted(getContext(), clientId, wordlistId, version, status);
+ return 1;
+ } else if (MetadataDbHelper.STATUS_INSTALLED == status) {
+ final String result = uri.getQueryParameter(QUERY_PARAMETER_DELETE_RESULT);
+ if (QUERY_PARAMETER_FAILURE.equals(result)) {
+ UpdateHandler.markAsBroken(getContext(), clientId, wordlistId, version);
+ }
+ final String localFilename =
+ wordList.getAsString(MetadataDbHelper.LOCAL_FILENAME_COLUMN);
+ final File f = getContext().getFileStreamPath(localFilename);
+ // f.delete() returns true if the file was successfully deleted, false otherwise
+ if (f.delete()) {
+ return 1;
+ } else {
+ return 0;
+ }
+ } else {
+ Log.e(TAG, "Attempt to delete a file whose status is " + status);
+ return 0;
+ }
+ }
+
+ /**
+ * Insert data into the provider. May be either a metadata source URL or some dictionary info.
+ *
+ * @param uri the designated content URI. See sUriMatcherV{1,2} for available URIs.
+ * @param values the values to insert for this content uri
+ * @return the URI for the newly inserted item. May be null if arguments don't allow for insert
+ */
+ @Override
+ public Uri insert(final Uri uri, final ContentValues values)
+ throws UnsupportedOperationException {
+ if (null == uri || null == values) return null; // Should never happen but let's be safe
+ PrivateLog.log("Insert, uri = " + uri.toString(), this);
+ 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);
+ }
+ break;
+ case DICTIONARY_V1_WHOLE_LIST:
+ case DICTIONARY_V1_DICT_INFO:
+ PrivateLog.log("Attempt to insert : " + uri, this);
+ 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, this);
+ throw new UnsupportedOperationException("Updating dictionary words is not supported");
+ }
+}
diff --git a/java/src/com/android/inputmethod/dictionarypack/DictionaryService.java b/java/src/com/android/inputmethod/dictionarypack/DictionaryService.java
new file mode 100644
index 000000000..5817eb498
--- /dev/null
+++ b/java/src/com/android/inputmethod/dictionarypack/DictionaryService.java
@@ -0,0 +1,242 @@
+/**
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.android.inputmethod.dictionarypack;
+
+import android.app.AlarmManager;
+import android.app.PendingIntent;
+import android.app.Service;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.os.IBinder;
+import android.text.format.DateUtils;
+import android.util.Log;
+import android.widget.Toast;
+
+import com.android.inputmethod.latin.R;
+
+import java.util.Locale;
+import java.util.Random;
+
+/**
+ * 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.getName();
+
+ /**
+ * The package name, to use in the intent actions.
+ */
+ private static final String PACKAGE_NAME = "com.android.android.inputmethod.latin";
+
+ /**
+ * The action of the intent to tell the dictionary provider to update now.
+ */
+ private static final String UPDATE_NOW_INTENT_ACTION = PACKAGE_NAME + ".UPDATE_NOW";
+
+ /**
+ * 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 = 4 * DateUtils.DAY_IN_MILLIS;
+
+ /**
+ * 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 = 6 * ((int)AlarmManager.INTERVAL_HOUR);
+
+ /**
+ * 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 = 14 * DateUtils.DAY_IN_MILLIS;
+
+ /**
+ * The last seen start Id. This must be stored because we must only call stopSelfResult() with
+ * the last seen Id, or the service won't stop.
+ */
+ private int mLastSeenStartId;
+
+ /**
+ * The command count. We need this because we need to not call stopSelfResult() while we still
+ * have commands running.
+ */
+ private int mCommandCount;
+
+ @Override
+ public void onCreate() {
+ mLastSeenStartId = 0;
+ mCommandCount = 0;
+ }
+
+ @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).
+ */
+ @Override
+ public synchronized int onStartCommand(final Intent intent, final int flags,
+ final int startId) {
+ final DictionaryService self = this;
+ mLastSeenStartId = startId;
+ mCommandCount += 1;
+ if (SHOW_DOWNLOAD_TOAST_INTENT_ACTION.equals(intent.getAction())) {
+ // This is a UI action, it can't be run in another thread
+ showStartDownloadingToast(this, LocaleUtils.constructLocaleFromString(
+ intent.getStringExtra(LOCALE_INTENT_ARGUMENT)));
+ } else {
+ // If it's a command that does not require UI, create a thread to do the work
+ // and return right away. DATE_CHANGED or UPDATE_NOW are examples of such commands.
+ new Thread("updateOrFinishDownload") {
+ @Override
+ public void run() {
+ dispatchBroadcast(self, intent);
+ synchronized(self) {
+ if (--mCommandCount <= 0) {
+ if (!stopSelfResult(mLastSeenStartId)) {
+ Log.e(TAG, "Can't stop ourselves");
+ }
+ }
+ }
+ }
+ }.start();
+ }
+ return Service.START_REDELIVER_INTENT;
+ }
+
+ private static void dispatchBroadcast(final Context context, final Intent intent) {
+ if (DATE_CHANGED_INTENT_ACTION.equals(intent.getAction())) {
+ // 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 (UPDATE_NOW_INTENT_ACTION.equals(intent.getAction())) {
+ // Intent to trigger an update now.
+ UpdateHandler.update(context, false);
+ } 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, do nothing.
+ if (!isLastUpdateAtLeastThisOld(context, UPDATE_FREQUENCY)) return;
+
+ PrivateLog.log("Date changed - registering alarm", context);
+ AlarmManager alarmManager = (AlarmManager)context.getSystemService(Context.ALARM_SERVICE);
+
+ // Best effort to wake between midnight and MAX_ALARM_DELAY 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);
+ final Intent updateIntent = new Intent(DictionaryService.UPDATE_NOW_INTENT_ACTION);
+ final PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0,
+ updateIntent, PendingIntent.FLAG_CANCEL_CURRENT);
+
+ // 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, context);
+ 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,
+ * update metadata now - and possibly take subsequent update actions.
+ */
+ public static void updateNowIfNotUpdatedInAVeryLongTime(final Context context) {
+ if (!isLastUpdateAtLeastThisOld(context, VERY_LONG_TIME)) return;
+ UpdateHandler.update(context, false);
+ }
+
+ /**
+ * Shows a toast informing the user that an automatic dictionary download is starting.
+ */
+ private static void showStartDownloadingToast(final Context context, 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/com/android/inputmethod/dictionarypack/DictionarySettingsActivity.java b/java/src/com/android/inputmethod/dictionarypack/DictionarySettingsActivity.java
new file mode 100644
index 000000000..684165240
--- /dev/null
+++ b/java/src/com/android/inputmethod/dictionarypack/DictionarySettingsActivity.java
@@ -0,0 +1,42 @@
+/**
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.android.inputmethod.dictionarypack;
+
+import android.content.Intent;
+import android.os.Bundle;
+import android.preference.PreferenceActivity;
+
+/**
+ * Preference screen.
+ */
+public final class DictionarySettingsActivity extends PreferenceActivity {
+ @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, DictionarySettingsFragment.class.getName());
+ 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;
+ }
+}
diff --git a/java/src/com/android/inputmethod/dictionarypack/DictionarySettingsFragment.java b/java/src/com/android/inputmethod/dictionarypack/DictionarySettingsFragment.java
new file mode 100644
index 000000000..f5526ddd7
--- /dev/null
+++ b/java/src/com/android/inputmethod/dictionarypack/DictionarySettingsFragment.java
@@ -0,0 +1,365 @@
+/**
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.android.inputmethod.dictionarypack;
+
+import android.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.Bundle;
+import android.preference.Preference;
+import android.preference.PreferenceFragment;
+import android.preference.PreferenceGroup;
+import android.text.format.DateUtils;
+import android.util.Log;
+import android.view.animation.AnimationUtils;
+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 com.android.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 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) {
+ mUpdateNowMenu = menu.add(Menu.NONE, MENU_UPDATE_NOW, 0, R.string.check_for_updates_now);
+ mUpdateNowMenu.setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM);
+ refreshNetworkState();
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ mChangedSettings = false;
+ UpdateHandler.registerUpdateEventListener(this);
+ final IntentFilter filter = new IntentFilter();
+ filter.addAction(ConnectivityManager.CONNECTIVITY_ACTION);
+ getActivity().registerReceiver(mConnectivityChangedReceiver, filter);
+ refreshNetworkState();
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+ final Activity activity = getActivity();
+ UpdateHandler.unregisterUpdateEventListener(this);
+ activity.unregisterReceiver(mConnectivityChangedReceiver);
+ if (mChangedSettings) {
+ final Intent newDictBroadcast = new Intent(UpdateHandler.NEW_DICTIONARY_INTENT_ACTION);
+ activity.sendBroadcast(newDictBroadcast);
+ mChangedSettings = false;
+ }
+ }
+
+ 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();
+ }
+
+ 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;
+ }
+
+ public void updateCycleCompleted() {}
+
+ private void refreshNetworkState() {
+ NetworkInfo info = mConnectivityManager.getActiveNetworkInfo();
+ boolean isConnected = null == info ? false : info.isConnected();
+ if (null != mUpdateNowMenu) mUpdateNowMenu.setEnabled(isConnected);
+ }
+
+ private void refreshInterface() {
+ final Activity activity = getActivity();
+ if (null == activity) return;
+ final long lastUpdateDate =
+ MetadataDbHelper.getLastUpdateDateForClient(getActivity(), mClientId);
+ final PreferenceGroup prefScreen = getPreferenceScreen();
+ final Collection<? extends Preference> prefList =
+ createInstalledDictSettingsCollection(mClientId);
+
+ final String updateNowSummary = getString(R.string.last_update) + " "
+ + DateUtils.formatDateTime(activity, lastUpdateDate,
+ DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_SHOW_TIME);
+
+ activity.runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ // TODO: display this somewhere
+ // if (0 != lastUpdate) mUpdateNowPreference.setSummary(updateNowSummary);
+ refreshNetworkState();
+
+ removeAnyDictSettings(prefScreen);
+ for (Preference preference : prefList) {
+ prefScreen.addPreference(preference);
+ }
+ }
+ });
+ }
+
+ private Preference createErrorMessage(final Activity activity, final int messageResource) {
+ final Preference message = new Preference(activity);
+ message.setTitle(messageResource);
+ message.setEnabled(false);
+ return message;
+ }
+
+ private 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://com.android.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<Preference>();
+ result.add(createErrorMessage(activity, R.string.cannot_connect_to_dict_service));
+ return result;
+ } else if (!cursor.moveToFirst()) {
+ final ArrayList<Preference> result = new ArrayList<Preference>();
+ result.add(createErrorMessage(activity, R.string.no_dictionaries_available));
+ return result;
+ } else {
+ final String systemLocaleString = Locale.getDefault().toString();
+ final TreeMap<String, WordListPreference> prefList =
+ new TreeMap<String, WordListPreference>();
+ 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);
+ 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);
+ // The key is sorted in lexicographic order, according to the match level, then
+ // the description.
+ final String key = matchLevelString + "." + description + "." + wordlistId;
+ final WordListPreference existingPref = prefList.get(key);
+ if (null == existingPref || hasPriority(status, existingPref.mStatus)) {
+ final WordListPreference pref = new WordListPreference(activity, mClientId,
+ wordlistId, version, locale, description, status);
+ prefList.put(key, pref);
+ }
+ } while (cursor.moveToNext());
+ return prefList.values();
+ }
+ }
+
+ /**
+ * Finds out if a given status has priority over another for display order.
+ *
+ * @param newStatus
+ * @param oldStatus
+ * @return whether newStatus has priority over oldStatus.
+ */
+ private static boolean hasPriority(final int newStatus, final int oldStatus) {
+ // Both of these should be one of MetadataDbHelper.STATUS_*
+ return newStatus > oldStatus;
+ }
+
+ @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() {
+ UpdateHandler.update(activity, true);
+ }
+ }.start();
+ }
+
+ private void cancelRefresh() {
+ UpdateHandler.unregisterUpdateEventListener(this);
+ final Context context = getActivity();
+ UpdateHandler.cancelUpdate(context,
+ MetadataDbHelper.getMetadataUriAsString(context, mClientId));
+ stopLoadingAnimation();
+ }
+
+ private void startLoadingAnimation() {
+ mLoadingView.setVisibility(View.VISIBLE);
+ getView().setVisibility(View.GONE);
+ mUpdateNowMenu.setTitle(R.string.cancel);
+ }
+
+ private void stopLoadingAnimation() {
+ final View preferenceView = getView();
+ final Activity activity = getActivity();
+ if (null == activity) return;
+ activity.runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ mLoadingView.setVisibility(View.GONE);
+ preferenceView.setVisibility(View.VISIBLE);
+ mLoadingView.startAnimation(AnimationUtils.loadAnimation(
+ getActivity(), android.R.anim.fade_out));
+ preferenceView.startAnimation(AnimationUtils.loadAnimation(
+ getActivity(), android.R.anim.fade_in));
+ mUpdateNowMenu.setTitle(R.string.check_for_updates_now);
+ }
+ });
+ }
+}
diff --git a/java/src/com/android/inputmethod/dictionarypack/DownloadOverMeteredDialog.java b/java/src/com/android/inputmethod/dictionarypack/DownloadOverMeteredDialog.java
new file mode 100644
index 000000000..d3c0a910f
--- /dev/null
+++ b/java/src/com/android/inputmethod/dictionarypack/DownloadOverMeteredDialog.java
@@ -0,0 +1,77 @@
+/*
+ * 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 com.android.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 com.android.inputmethod.latin.R;
+
+import java.util.Locale;
+
+/**
+ * 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(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 Locale locale = LocaleUtils.constructLocaleFromString(localeString);
+ final String language = (null == locale ? "" : locale.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)));
+ }
+
+ public void onClickDeny(final View v) {
+ UpdateHandler.setDownloadOverMeteredSetting(this, false);
+ finish();
+ }
+
+ public void onClickAllow(final View v) {
+ UpdateHandler.setDownloadOverMeteredSetting(this, true);
+ UpdateHandler.installIfNeverRequested(this, mClientId, mWordListToDownload,
+ false /* mayPrompt */);
+ finish();
+ }
+}
diff --git a/java/src/com/android/inputmethod/dictionarypack/DownloadRecord.java b/java/src/com/android/inputmethod/dictionarypack/DownloadRecord.java
new file mode 100644
index 000000000..c26299027
--- /dev/null
+++ b/java/src/com/android/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 com.android.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/com/android/inputmethod/dictionarypack/EventHandler.java b/java/src/com/android/inputmethod/dictionarypack/EventHandler.java
new file mode 100644
index 000000000..96c4a8305
--- /dev/null
+++ b/java/src/com/android/inputmethod/dictionarypack/EventHandler.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.android.inputmethod.dictionarypack;
+
+import com.android.inputmethod.latin.LatinIME;
+import com.android.inputmethod.latin.R;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.util.Log;
+
+public final class EventHandler extends BroadcastReceiver {
+ private static final String TAG = EventHandler.class.getName();
+
+ /**
+ * 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/com/android/inputmethod/dictionarypack/LocaleUtils.java b/java/src/com/android/inputmethod/dictionarypack/LocaleUtils.java
new file mode 100644
index 000000000..d0e8446f5
--- /dev/null
+++ b/java/src/com/android/inputmethod/dictionarypack/LocaleUtils.java
@@ -0,0 +1,204 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.android.inputmethod.dictionarypack;
+
+import android.content.res.Configuration;
+import android.content.res.Resources;
+import android.text.TextUtils;
+
+import java.util.HashMap;
+import java.util.Locale;
+
+/**
+ * A class to help with handling Locales in string form.
+ *
+ * This file has the same meaning and features (and shares all of its code) with the one with the
+ * same name in Latin IME. They need to be kept synchronized; for any update/bugfix to
+ * this file, consider also updating/fixing the version in Latin IME.
+ */
+public final class LocaleUtils {
+ private LocaleUtils() {
+ // Intentional empty constructor for utility class.
+ }
+
+ // Locale match level constants.
+ // A higher level of match is guaranteed to have a higher numerical value.
+ // Some room is left within constants to add match cases that may arise necessary
+ // in the future, for example differentiating between the case where the countries
+ // are both present and different, and the case where one of the locales does not
+ // specify the countries. This difference is not needed now.
+
+ // Nothing matches.
+ public static final int LOCALE_NO_MATCH = 0;
+ // The languages matches, but the country are different. Or, the reference locale requires a
+ // country and the tested locale does not have one.
+ public static final int LOCALE_LANGUAGE_MATCH_COUNTRY_DIFFER = 3;
+ // The languages and country match, but the variants are different. Or, the reference locale
+ // requires a variant and the tested locale does not have one.
+ public static final int LOCALE_LANGUAGE_AND_COUNTRY_MATCH_VARIANT_DIFFER = 6;
+ // The required locale is null or empty so it will accept anything, and the tested locale
+ // is non-null and non-empty.
+ public static final int LOCALE_ANY_MATCH = 10;
+ // The language matches, and the tested locale specifies a country but the reference locale
+ // does not require one.
+ public static final int LOCALE_LANGUAGE_MATCH = 15;
+ // The language and the country match, and the tested locale specifies a variant but the
+ // reference locale does not require one.
+ public static final int LOCALE_LANGUAGE_AND_COUNTRY_MATCH = 20;
+ // The compared locales are fully identical. This is the best match level.
+ public static final int LOCALE_FULL_MATCH = 30;
+
+ // The level at which a match is "normally" considered a locale match with standard algorithms.
+ // Don't use this directly, use #isMatch to test.
+ private static final int LOCALE_MATCH = LOCALE_ANY_MATCH;
+
+ // Make this match the maximum match level. If this evolves to have more than 2 digits
+ // when written in base 10, also adjust the getMatchLevelSortedString method.
+ private static final int MATCH_LEVEL_MAX = 30;
+
+ /**
+ * Return how well a tested locale matches a reference locale.
+ *
+ * This will check the tested locale against the reference locale and return a measure of how
+ * a well it matches the reference. The general idea is that the tested locale has to match
+ * every specified part of the required locale. A full match occur when they are equal, a
+ * partial match when the tested locale agrees with the reference locale but is more specific,
+ * and a difference when the tested locale does not comply with all requirements from the
+ * reference locale.
+ * In more detail, if the reference locale specifies at least a language and the testedLocale
+ * does not specify one, or specifies a different one, LOCALE_NO_MATCH is returned. If the
+ * reference locale is empty or null, it will match anything - in the form of LOCALE_FULL_MATCH
+ * if the tested locale is empty or null, and LOCALE_ANY_MATCH otherwise. If the reference and
+ * tested locale agree on the language, but not on the country,
+ * LOCALE_LANGUAGE_MATCH_COUNTRY_DIFFER is returned if the reference locale specifies a country,
+ * and LOCALE_LANGUAGE_MATCH otherwise.
+ * If they agree on both the language and the country, but not on the variant,
+ * LOCALE_LANGUAGE_AND_COUNTRY_MATCH_VARIANT_DIFFER is returned if the reference locale
+ * specifies a variant, and LOCALE_LANGUAGE_AND_COUNTRY_MATCH otherwise. If everything matches,
+ * LOCALE_FULL_MATCH is returned.
+ * Examples:
+ * en <=> en_US => LOCALE_LANGUAGE_MATCH
+ * en_US <=> en => LOCALE_LANGUAGE_MATCH_COUNTRY_DIFFER
+ * en_US_POSIX <=> en_US_Android => LOCALE_LANGUAGE_AND_COUNTRY_MATCH_VARIANT_DIFFER
+ * en_US <=> en_US_Android => LOCALE_LANGUAGE_AND_COUNTRY_MATCH
+ * sp_US <=> en_US => LOCALE_NO_MATCH
+ * de <=> de => LOCALE_FULL_MATCH
+ * en_US <=> en_US => LOCALE_FULL_MATCH
+ * "" <=> en_US => LOCALE_ANY_MATCH
+ *
+ * @param referenceLocale the reference locale to test against.
+ * @param testedLocale the locale to test.
+ * @return a constant that measures how well the tested locale matches the reference locale.
+ */
+ public static int getMatchLevel(final String referenceLocale, final String testedLocale) {
+ if (TextUtils.isEmpty(referenceLocale)) {
+ return TextUtils.isEmpty(testedLocale) ? LOCALE_FULL_MATCH : LOCALE_ANY_MATCH;
+ }
+ if (null == testedLocale) return LOCALE_NO_MATCH;
+ final String[] referenceParams = referenceLocale.split("_", 3);
+ final String[] testedParams = testedLocale.split("_", 3);
+ // By spec of String#split, [0] cannot be null and length cannot be 0.
+ if (!referenceParams[0].equals(testedParams[0])) return LOCALE_NO_MATCH;
+ switch (referenceParams.length) {
+ case 1:
+ return 1 == testedParams.length ? LOCALE_FULL_MATCH : LOCALE_LANGUAGE_MATCH;
+ case 2:
+ if (1 == testedParams.length) return LOCALE_LANGUAGE_MATCH_COUNTRY_DIFFER;
+ if (!referenceParams[1].equals(testedParams[1]))
+ return LOCALE_LANGUAGE_MATCH_COUNTRY_DIFFER;
+ if (3 == testedParams.length) return LOCALE_LANGUAGE_AND_COUNTRY_MATCH;
+ return LOCALE_FULL_MATCH;
+ case 3:
+ if (1 == testedParams.length) return LOCALE_LANGUAGE_MATCH_COUNTRY_DIFFER;
+ if (!referenceParams[1].equals(testedParams[1]))
+ return LOCALE_LANGUAGE_MATCH_COUNTRY_DIFFER;
+ if (2 == testedParams.length) return LOCALE_LANGUAGE_AND_COUNTRY_MATCH_VARIANT_DIFFER;
+ if (!referenceParams[2].equals(testedParams[2]))
+ return LOCALE_LANGUAGE_AND_COUNTRY_MATCH_VARIANT_DIFFER;
+ return LOCALE_FULL_MATCH;
+ }
+ // It should be impossible to come here
+ return LOCALE_NO_MATCH;
+ }
+
+ /**
+ * Return a string that represents this match level, with better matches first.
+ *
+ * The strings are sorted in lexicographic order: a better match will always be less than
+ * a worse match when compared together.
+ */
+ public static String getMatchLevelSortedString(final int matchLevel) {
+ // This works because the match levels are 0~99 (actually 0~30)
+ // Ideally this should use a number of digits equals to the 1og10 of the greater matchLevel
+ return String.format("%02d", MATCH_LEVEL_MAX - matchLevel);
+ }
+
+ /**
+ * Find out whether a match level should be considered a match.
+ *
+ * This method takes a match level as returned by the #getMatchLevel method, and returns whether
+ * it should be considered a match in the usual sense with standard Locale functions.
+ *
+ * @param level the match level, as returned by getMatchLevel.
+ * @return whether this is a match or not.
+ */
+ public static boolean isMatch(final int level) {
+ return LOCALE_MATCH <= level;
+ }
+
+ /**
+ * Sets the system locale for this process.
+ *
+ * @param res the resources to use. Pass current resources.
+ * @param newLocale the locale to change to.
+ * @return the old locale.
+ */
+ public static Locale setSystemLocale(final Resources res, final Locale newLocale) {
+ final Configuration conf = res.getConfiguration();
+ final Locale saveLocale = conf.locale;
+ conf.locale = newLocale;
+ res.updateConfiguration(conf, res.getDisplayMetrics());
+ return saveLocale;
+ }
+
+ private static final HashMap<String, Locale> sLocaleCache = new HashMap<String, Locale>();
+
+ /**
+ * Creates a locale from a string specification.
+ */
+ public static Locale constructLocaleFromString(final String localeStr) {
+ if (localeStr == null)
+ return null;
+ synchronized (sLocaleCache) {
+ if (sLocaleCache.containsKey(localeStr))
+ return sLocaleCache.get(localeStr);
+ Locale retval = null;
+ String[] localeParams = localeStr.split("_", 3);
+ if (localeParams.length == 1) {
+ retval = new Locale(localeParams[0]);
+ } else if (localeParams.length == 2) {
+ retval = new Locale(localeParams[0], localeParams[1]);
+ } else if (localeParams.length == 3) {
+ retval = new Locale(localeParams[0], localeParams[1], localeParams[2]);
+ }
+ if (retval != null) {
+ sLocaleCache.put(localeStr, retval);
+ }
+ return retval;
+ }
+ }
+}
diff --git a/java/src/com/android/inputmethod/dictionarypack/LogProblemReporter.java b/java/src/com/android/inputmethod/dictionarypack/LogProblemReporter.java
new file mode 100644
index 000000000..c127ad540
--- /dev/null
+++ b/java/src/com/android/inputmethod/dictionarypack/LogProblemReporter.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.android.inputmethod.dictionarypack;
+
+import android.util.Log;
+
+/**
+ * A very simple problem reporter.
+ */
+final class LogProblemReporter implements ProblemReporter {
+ private final String TAG;
+
+ public LogProblemReporter(final String tag) {
+ TAG = tag;
+ }
+
+ public void report(final Exception e) {
+ Log.e(TAG, "Reporting problem : " + e);
+ }
+}
diff --git a/java/src/com/android/inputmethod/dictionarypack/MD5Calculator.java b/java/src/com/android/inputmethod/dictionarypack/MD5Calculator.java
new file mode 100644
index 000000000..e47e86e4b
--- /dev/null
+++ b/java/src/com/android/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 com.android.inputmethod.dictionarypack;
+
+import java.io.InputStream;
+import java.io.IOException;
+import java.security.MessageDigest;
+
+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/com/android/inputmethod/dictionarypack/MetadataDbHelper.java b/java/src/com/android/inputmethod/dictionarypack/MetadataDbHelper.java
new file mode 100644
index 000000000..55f545aad
--- /dev/null
+++ b/java/src/com/android/inputmethod/dictionarypack/MetadataDbHelper.java
@@ -0,0 +1,978 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.android.inputmethod.dictionarypack;
+
+import android.content.ContentResolver;
+import android.content.ContentValues;
+import android.content.Context;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteOpenHelper;
+import android.provider.Settings;
+import android.text.TextUtils;
+import android.util.Log;
+
+import com.android.inputmethod.latin.R;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.TreeMap;
+
+/**
+ * Various helper functions for the state database
+ */
+public class MetadataDbHelper extends SQLiteOpenHelper {
+
+ @SuppressWarnings("unused")
+ 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 = 5;
+ // This is the current database version. It should be updated when the database schema
+ // gets updated. It is passed to the framework constructor of SQLiteOpenHelper, so
+ // that's what the framework uses to track our database version.
+ private static final int METADATA_DATABASE_VERSION = 5;
+
+ private final static long NOT_A_DOWNLOAD_ID = -1;
+
+ public static final String METADATA_TABLE_NAME = "pendingUpdates";
+ private 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 int COLUMN_COUNT = 13;
+
+ private static final String CLIENT_CLIENT_ID_COLUMN = "clientid";
+ private static final String CLIENT_METADATA_URI_COLUMN = "uri";
+ 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;
+
+ // 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,"
+ + "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_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 };
+ // 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<String, MetadataDbHelper>();
+ 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, 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);
+ 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);
+ }
+
+ /**
+ * Upgrade the database. Upgrade from version 3 is supported.
+ */
+ @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) {
+ // Upgrade from version METADATA_DATABASE_INITIAL_VERSION to version
+ // METADATA_DATABASE_VERSION_WITH_CLIENT_ID
+ if (TextUtils.isEmpty(mClientId)) {
+ // Only the default database should contain the client table.
+ // Anyway in version 3 only the default table existed so the emptyness
+ // test should always be true, but better check to be sure.
+ createClientTable(db);
+ }
+ } else {
+ // Version 3 was the earliest version, so we should never come here. If we do, we
+ // have no idea what this database is, so we'd better wipe it off.
+ db.execSQL("DROP TABLE IF EXISTS " + METADATA_TABLE_NAME);
+ db.execSQL("DROP TABLE IF EXISTS " + CLIENT_TABLE_NAME);
+ onCreate(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);
+ }
+
+ /**
+ * 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 = getDb(context, null);
+ final Cursor cursor = defaultDb.query(CLIENT_TABLE_NAME,
+ new String[] { CLIENT_METADATA_URI_COLUMN },
+ CLIENT_CLIENT_ID_COLUMN + " = ?", new String[] { clientId },
+ null, null, null, null);
+ try {
+ if (!cursor.moveToFirst()) return null;
+ return cursor.getString(0); // Only one column, return it
+ } finally {
+ cursor.close();
+ }
+ }
+
+ /**
+ * Update the last metadata update time for all clients using a particular URI.
+ *
+ * All clients using this metadata URI will be indicated as having been updated now.
+ * 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(),
+ context);
+ final ContentValues values = new ContentValues();
+ values.put(CLIENT_LAST_UPDATE_DATE_COLUMN, System.currentTimeMillis());
+ final SQLiteDatabase defaultDb = getDb(context, null);
+ defaultDb.update(CLIENT_TABLE_NAME, values,
+ CLIENT_METADATA_URI_COLUMN + " = ?", new String[] { uri });
+ }
+
+ /**
+ * 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 client ID.
+ *
+ * This will retrieve the download ID for the metadata file associated with a client ID.
+ * If there is no metadata download in progress for this client, it will return NOT_AN_ID.
+ *
+ * @param context a context instance to open the database on
+ * @param clientId the client ID to retrieve the metadata download ID of
+ * @return the metadata download ID, or NOT_AN_ID if no download is in progress
+ */
+ public static long getMetadataDownloadIdForClient(final Context context,
+ final String clientId) {
+ SQLiteDatabase defaultDb = getDb(context, null);
+ final Cursor cursor = defaultDb.query(CLIENT_TABLE_NAME,
+ new String[] { CLIENT_PENDINGID_COLUMN },
+ CLIENT_CLIENT_ID_COLUMN + " = ?", new String[] { clientId },
+ null, null, null, null);
+ try {
+ if (!cursor.moveToFirst()) return UpdateHandler.NOT_AN_ID;
+ return cursor.getInt(0); // Only one column, return it
+ } 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 checksum, 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(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 (!result.containsKey(WORDLISTID_COLUMN) || !result.containsKey(LOCALE_COLUMN)) {
+ throw new BadFormatException();
+ }
+ // 0 for the pending id, because there is none
+ if (!result.containsKey(PENDINGID_COLUMN)) result.put(PENDINGID_COLUMN, 0);
+ // This is a binary blob of a dictionary
+ if (!result.containsKey(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 (!result.containsKey(STATUS_COLUMN)) result.put(STATUS_COLUMN, STATUS_INSTALLED);
+ // No description unless specified, because we can't guess it
+ if (!result.containsKey(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 (!result.containsKey(LOCAL_FILENAME_COLUMN)) result.put(LOCAL_FILENAME_COLUMN, "_");
+ // No remote file name : this can't be downloaded. Unless specified.
+ if (!result.containsKey(REMOTE_FILENAME_COLUMN)) result.put(REMOTE_FILENAME_COLUMN, "");
+ // 0 for the update date : 1970/1/1. Unless specified.
+ if (!result.containsKey(DATE_COLUMN)) result.put(DATE_COLUMN, 0);
+ // Checksum unknown unless specified
+ if (!result.containsKey(CHECKSUM_COLUMN)) result.put(CHECKSUM_COLUMN, "");
+ // No filesize unless specified
+ if (!result.containsKey(FILESIZE_COLUMN)) result.put(FILESIZE_COLUMN, 0);
+ // Smallest possible version unless specified
+ if (!result.containsKey(VERSION_COLUMN)) result.put(VERSION_COLUMN, 1);
+ // Assume current format unless specified
+ if (!result.containsKey(FORMATVERSION_COLUMN))
+ result.put(FORMATVERSION_COLUMN, UpdateHandler.MAXIMUM_SUPPORTED_FORMAT_VERSION);
+ // No flags unless specified
+ if (!result.containsKey(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, CHECKSUM_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);
+ // 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.
+ final ContentValues result = getFirstLineAsContentValues(cursor);
+ cursor.close();
+ return result;
+ }
+
+ /**
+ * 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 Android 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);
+ // 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.
+ final ContentValues result = getFirstLineAsContentValues(cursor);
+ cursor.close();
+ return result;
+ }
+
+ /**
+ * 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, 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<DownloadRecord>();
+ 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.
+ */
+ 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 + "= ?",
+ new String[] { id, Integer.toString(version) }, null, null, null);
+ // This is a lookup by primary key, so there can't be more than one result.
+ final ContentValues result = getFirstLineAsContentValues(cursor);
+ cursor.close();
+ return result;
+ }
+
+ /**
+ * 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");
+ // This is a lookup by primary key, so there can't be more than one result.
+ final ContentValues result = getFirstLineAsContentValues(cursor);
+ cursor.close();
+ return result;
+ }
+
+ /**
+ * 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 only the metadata URI, but 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.
+ * The passed values must also include a non-empty metadata URI in the
+ * CLIENT_METADATA_URI_COLUMN column.
+ * 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) {
+ // Sanity check the content values
+ final String valuesClientId = values.getAsString(CLIENT_CLIENT_ID_COLUMN);
+ final String valuesMetadataUri = values.getAsString(CLIENT_METADATA_URI_COLUMN);
+ // Empty string is a valid client ID, but external apps may not configure it.
+ // Empty string is a valid metadata URI if the client does not want updates.
+ if (TextUtils.isEmpty(valuesClientId) || null == valuesMetadataUri) {
+ // We need both these columns to be filled in
+ Utils.l("Missing parameter for updateClientInfo");
+ return;
+ }
+ if (!clientId.equals(valuesClientId)) {
+ // Mismatch! The client violates the protocol.
+ Utils.l("Received an updateClientInfo request for ", clientId, " but the values "
+ + "contain a different ID : ", valuesClientId);
+ return;
+ }
+ 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
+ * register the download ID for all clients using this metadata URI 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);
+ final SQLiteDatabase defaultDb = getDb(context, "");
+ defaultDb.update(CLIENT_TABLE_NAME, values,
+ CLIENT_METADATA_URI_COLUMN + " = ?", new String[] { uri });
+ }
+
+ /**
+ * 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:
+ Utils.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<String>();
+ 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);
+ 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 {
+ Utils.l("Setting for removal", c.getString(filenameIndex));
+ filenames.add(c.getString(filenameIndex));
+ } while (c.moveToNext());
+ }
+
+ 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);
+ }
+}
diff --git a/java/src/com/android/inputmethod/dictionarypack/MetadataHandler.java b/java/src/com/android/inputmethod/dictionarypack/MetadataHandler.java
new file mode 100644
index 000000000..a0147b6d6
--- /dev/null
+++ b/java/src/com/android/inputmethod/dictionarypack/MetadataHandler.java
@@ -0,0 +1,141 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.android.inputmethod.dictionarypack;
+
+import android.content.Context;
+import android.database.Cursor;
+
+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 {
+ @SuppressWarnings("unused")
+ private static final String TAG = "DictionaryProvider:" + 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 final static 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<WordListMetadata>();
+
+ if (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 checksumIndex = results.getColumnIndex(MetadataDbHelper.CHECKSUM_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(checksumIndex),
+ results.getString(localFilenameIndex),
+ results.getString(remoteFilenameIndex),
+ results.getInt(versionIndex),
+ results.getInt(formatVersionIndex),
+ 0, results.getString(localeColumn)));
+ } while (results.moveToNext());
+
+ results.close();
+ }
+ 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);
+ final List<WordListMetadata> resultList = makeMetadataObject(results);
+ results.close();
+ return resultList;
+ }
+
+ /**
+ * 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/com/android/inputmethod/dictionarypack/MetadataParser.java b/java/src/com/android/inputmethod/dictionarypack/MetadataParser.java
new file mode 100644
index 000000000..27670fddf
--- /dev/null
+++ b/java/src/com/android/inputmethod/dictionarypack/MetadataParser.java
@@ -0,0 +1,111 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.android.inputmethod.dictionarypack;
+
+import android.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 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<String, String>();
+ 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(CHECKSUM_FIELD_NAME),
+ 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<WordListMetadata>();
+ 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/com/android/inputmethod/dictionarypack/PrivateLog.java b/java/src/com/android/inputmethod/dictionarypack/PrivateLog.java
new file mode 100644
index 000000000..8593c1c9b
--- /dev/null
+++ b/java/src/com/android/inputmethod/dictionarypack/PrivateLog.java
@@ -0,0 +1,109 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.android.inputmethod.dictionarypack;
+
+import android.content.ContentProvider;
+import android.content.ContentValues;
+import android.content.Context;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteOpenHelper;
+
+import java.text.SimpleDateFormat;
+import java.util.Date;
+
+/**
+ * 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);";
+
+ private static final SimpleDateFormat sDateFormat =
+ new SimpleDateFormat("yyyy/MM/dd HH:mm:ss");
+
+ private static PrivateLog sInstance = new PrivateLog();
+ private static DebugHelper mDebugHelper = null;
+
+ private PrivateLog() {
+ }
+
+ public static synchronized PrivateLog getInstance(final Context context) {
+ if (!DEBUG) return sInstance;
+ synchronized(PrivateLog.class) {
+ if (sInstance.mDebugHelper == null) {
+ sInstance.mDebugHelper = new DebugHelper(context);
+ }
+ return sInstance;
+ }
+ }
+
+ private static class DebugHelper extends SQLiteOpenHelper {
+
+ private 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");
+ }
+
+ private 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, Context context) {
+ if (!DEBUG) return;
+ final SQLiteDatabase l = getInstance(context).mDebugHelper.getWritableDatabase();
+ mDebugHelper.insert(l, event);
+ }
+
+ public static void log(String event, ContentProvider provider) {
+ if (!DEBUG) return;
+ final SQLiteDatabase l =
+ getInstance(provider.getContext()).mDebugHelper.getWritableDatabase();
+ mDebugHelper.insert(l, event);
+ }
+}
diff --git a/java/src/com/android/inputmethod/dictionarypack/ProblemReporter.java b/java/src/com/android/inputmethod/dictionarypack/ProblemReporter.java
new file mode 100644
index 000000000..632819aa3
--- /dev/null
+++ b/java/src/com/android/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 com.android.inputmethod.dictionarypack;
+
+/**
+ * A simple interface to report problems.
+ */
+public interface ProblemReporter {
+ public void report(Exception e);
+}
diff --git a/java/src/com/android/inputmethod/dictionarypack/UpdateHandler.java b/java/src/com/android/inputmethod/dictionarypack/UpdateHandler.java
new file mode 100644
index 000000000..89cf6ed88
--- /dev/null
+++ b/java/src/com/android/inputmethod/dictionarypack/UpdateHandler.java
@@ -0,0 +1,1088 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.android.inputmethod.dictionarypack;
+
+import android.app.DownloadManager;
+import android.app.DownloadManager.Query;
+import android.app.DownloadManager.Request;
+import android.app.Notification;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+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.ConnectivityManager;
+import android.net.Uri;
+import android.os.ParcelFileDescriptor;
+import android.text.TextUtils;
+import android.util.Log;
+
+import com.android.inputmethod.compat.ConnectivityManagerCompatUtils;
+import com.android.inputmethod.compat.DownloadManagerCompatUtils;
+import com.android.inputmethod.latin.R;
+
+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.Locale;
+import java.util.Set;
+import java.util.TreeSet;
+
+/**
+ * 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 = 2;
+
+ // 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";
+
+ /**
+ * 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.
+ public static final String NEW_DICTIONARY_INTENT_ACTION =
+ "com.android.inputmethod.dictionarypack.newdict";
+
+ // 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 {
+ public void downloadedMetadata(boolean succeeded);
+ public void wordListDownloadFinished(String wordListId, boolean succeeded);
+ public 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
+ * @param updateNow Whether we should update NOW, or respect bandwidth policies
+ */
+ public static void update(final Context context, final boolean updateNow) {
+ // TODO: loop through all clients instead of only doing the default one.
+ final TreeSet<String> uris = new TreeSet<String>();
+ final Cursor cursor = MetadataDbHelper.queryClientIds(context);
+ if (null == cursor) return;
+ try {
+ if (!cursor.moveToFirst()) return;
+ do {
+ final String clientId = cursor.getString(0);
+ if (TextUtils.isEmpty(clientId)) continue; // This probably can't happen
+ final String metadataUri =
+ MetadataDbHelper.getMetadataUriAsString(context, clientId);
+ PrivateLog.log("Update for clientId " + Utils.s(clientId), context);
+ Utils.l("Update for clientId", clientId, " which uses URI ", metadataUri);
+ uris.add(metadataUri);
+ } while (cursor.moveToNext());
+ } finally {
+ cursor.close();
+ }
+ 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, updateNow, metadataUri);
+ }
+ }
+ }
+
+ /**
+ * Download latest metadata from the server through DownloadManager for all relevant clients
+ *
+ * @param context The context for retrieving resources
+ * @param updateNow Whether we should update NOW, or respect bandwidth policies
+ * @param metadataUri The client to update
+ */
+ private static void updateClientsWithMetadataUri(final Context context,
+ final boolean updateNow, final String metadataUri) {
+ PrivateLog.log("Update for metadata URI " + Utils.s(metadataUri), context);
+ final Request metadataRequest = new Request(Uri.parse(metadataUri));
+ Utils.l("Request =", metadataRequest);
+
+ final Resources res = context.getResources();
+ // By default, download over roaming is allowed and all network types are allowed too.
+ if (!updateNow) {
+ final boolean allowedOverMetered = res.getBoolean(R.bool.allow_over_metered);
+ // If we don't have to update NOW, then only do it over non-metered connections.
+ if (DownloadManagerCompatUtils.hasSetAllowedOverMetered()) {
+ DownloadManagerCompatUtils.setAllowedOverMetered(metadataRequest,
+ allowedOverMetered);
+ } else if (!allowedOverMetered) {
+ metadataRequest.setAllowedNetworkTypes(Request.NETWORK_WIFI);
+ }
+ metadataRequest.setAllowedOverRoaming(res.getBoolean(R.bool.allow_over_roaming));
+ }
+ final boolean notificationVisible = updateNow
+ ? res.getBoolean(R.bool.display_notification_for_user_requested_update)
+ : res.getBoolean(R.bool.display_notification_for_auto_update);
+
+ metadataRequest.setTitle(res.getString(R.string.download_description));
+ metadataRequest.setNotificationVisibility(notificationVisible
+ ? Request.VISIBILITY_VISIBLE : Request.VISIBILITY_HIDDEN);
+ metadataRequest.setVisibleInDownloadsUi(
+ res.getBoolean(R.bool.metadata_downloads_visible_in_download_UI));
+
+ final DownloadManager manager =
+ (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE);
+ if (null == manager) {
+ // Download manager is not installed or disabled.
+ // TODO: fall back to self-managed download?
+ return;
+ }
+ cancelUpdateWithDownloadManager(context, metadataUri, manager);
+ final long downloadId;
+ synchronized (sSharedIdProtector) {
+ downloadId = manager.enqueue(metadataRequest);
+ Utils.l("Metadata download requested with id", downloadId);
+ // If there is already 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);
+ }
+ PrivateLog.log("Requested download with id " + downloadId, context);
+ }
+
+ /**
+ * Cancels a pending update, if there is one.
+ *
+ * If none, this is a no-op.
+ *
+ * @param context the context to open the database on
+ * @param clientId the id of the client
+ * @param manager an instance of DownloadManager
+ */
+ private static void cancelUpdateWithDownloadManager(final Context context,
+ final String clientId, final DownloadManager manager) {
+ synchronized (sSharedIdProtector) {
+ final long metadataDownloadId =
+ MetadataDbHelper.getMetadataDownloadIdForClient(context, clientId);
+ if (NOT_AN_ID == metadataDownloadId) return;
+ manager.remove(metadataDownloadId);
+ writeMetadataDownloadId(context,
+ MetadataDbHelper.getMetadataUriAsString(context, clientId), 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);
+ }
+ }
+
+ /**
+ * Cancels a pending update, if there is one.
+ *
+ * If there is none, this is a no-op. This is a helper method that gets the
+ * download manager service.
+ *
+ * @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 DownloadManager manager =
+ (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE);
+ if (null != manager) cancelUpdateWithDownloadManager(context, clientId, manager);
+ }
+
+ /**
+ * 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 the 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 DownloadManager manager, final Request request,
+ final SQLiteDatabase db, final String id, final int version) {
+ Utils.l("RegisterDownloadRequest for word list id : ", id, ", version ", version);
+ final long downloadId;
+ synchronized (sSharedIdProtector) {
+ downloadId = manager.enqueue(request);
+ Utils.l("Download requested with id", downloadId);
+ MetadataDbHelper.markEntryAsDownloading(db, id, version, downloadId);
+ }
+ return downloadId;
+ }
+
+ /**
+ * Retrieve information about a specific download from DownloadManager.
+ */
+ private static CompletedDownloadInfo getCompletedDownloadInfo(final DownloadManager 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);
+ uri = cursor.getString(columnUri);
+ 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 (null == record.mAttributes) {
+ 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);
+ PrivateLog.log("Download finished with id " + fileId, context);
+ Utils.l("DownloadFinished with id", fileId);
+ if (NOT_AN_ID == fileId) return; // Spurious wake-up: ignore
+
+ final DownloadManager manager =
+ (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE);
+ final CompletedDownloadInfo downloadInfo = getCompletedDownloadInfo(manager, fileId);
+
+ final ArrayList<DownloadRecord> recordList =
+ getDownloadRecordsForCompletedDownloadInfo(context, downloadInfo);
+ if (null == recordList) return; // It was someone else's download.
+ Utils.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);
+ }
+ } finally {
+ if (record.isMetadata()) {
+ publishUpdateMetadataCompleted(context, downloadSuccessful);
+ } else {
+ 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);
+ }
+
+ private 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", context);
+ Utils.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 DownloadManager manager,
+ final long fileId) {
+ try {
+ // {@link handleWordList(Context,InputStream,ContentValues)}.
+ // Handle the downloaded file according to its type
+ if (downloadRecord.isMetadata()) {
+ Utils.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 {
+ Utils.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<T>(src);
+ }
+
+ /**
+ * Warn Android Keyboard that the state of dictionaries changed and it should refresh its data.
+ */
+ private static void signalNewDictionaryState(final Context context) {
+ final Intent newDictBroadcast = new Intent(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
+ */
+ private static void handleMetadata(final Context context, final InputStream stream,
+ final String clientId) throws IOException, BadFormatException {
+ Utils.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();
+ }
+
+ Utils.l("Downloaded metadata :", newMetadata);
+ PrivateLog.log("Downloaded metadata\n" + newMetadata, context);
+
+ 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.
+ Utils.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, context);
+
+ 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 {
+ Utils.l("Copying files");
+ if (!(in instanceof FileInputStream) || !(out instanceof FileOutputStream)) {
+ Utils.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.
+ Utils.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 {
+ Utils.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 {
+ Utils.l("Entering openTempFileOutput");
+ final File dir = context.getFilesDir();
+ final File f = File.createTempFile(locale + "___", DICT_FILE_SUFFIX, dir);
+ Utils.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, List<WordListMetadata> from, List<WordListMetadata> to) {
+ final ActionBatch actions = new ActionBatch();
+ // Upgrade existing word lists
+ Utils.l("Comparing dictionaries");
+ final Set<String> wordListIds = new TreeSet<String>();
+ // TODO: Can these be null?
+ if (null == from) from = new ArrayList<WordListMetadata>();
+ if (null == to) to = new ArrayList<WordListMetadata>();
+ for (WordListMetadata wlData : from) wordListIds.add(wlData.mId);
+ for (WordListMetadata wlData : to) wordListIds.add(wlData.mId);
+ for (String id : wordListIds) {
+ final WordListMetadata currentInfo = MetadataHandler.findWordListById(from, id);
+ final WordListMetadata metadataInfo = MetadataHandler.findWordListById(to, 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;
+ Utils.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 Android 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 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, false));
+ } 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 Android 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);
+ }
+
+ /**
+ * Shows the notification that informs the user a dictionary is available.
+ *
+ * When this notification is clicked, the dialog for downloading the dictionary
+ * over a metered connection is shown.
+ */
+ private static void showDictionaryAvailableNotification(final Context context,
+ final String clientId, final ContentValues installCandidate) {
+ final String localeString = installCandidate.getAsString(MetadataDbHelper.LOCALE_COLUMN);
+ final Intent intent = new Intent();
+ intent.setClass(context, DownloadOverMeteredDialog.class);
+ intent.putExtra(DownloadOverMeteredDialog.CLIENT_ID_KEY, clientId);
+ intent.putExtra(DownloadOverMeteredDialog.WORDLIST_TO_DOWNLOAD_KEY,
+ installCandidate.getAsString(MetadataDbHelper.WORDLISTID_COLUMN));
+ intent.putExtra(DownloadOverMeteredDialog.SIZE_KEY,
+ installCandidate.getAsInteger(MetadataDbHelper.FILESIZE_COLUMN));
+ intent.putExtra(DownloadOverMeteredDialog.LOCALE_KEY, localeString);
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS);
+ final PendingIntent notificationIntent = PendingIntent.getActivity(context,
+ 0 /* requestCode */, intent,
+ PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_ONE_SHOT);
+ final NotificationManager notificationManager =
+ (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
+ // None of those are expected to happen, but just in case...
+ if (null == notificationIntent || null == notificationManager) return;
+
+ final Locale locale = LocaleUtils.constructLocaleFromString(localeString);
+ final String language = (null == locale ? "" : locale.getDisplayLanguage());
+ final String titleFormat = context.getString(R.string.dict_available_notification_title);
+ final String notificationTitle = String.format(titleFormat, language);
+ final Notification notification = new Notification.Builder(context)
+ .setAutoCancel(true)
+ .setContentIntent(notificationIntent)
+ .setContentTitle(notificationTitle)
+ .setContentText(context.getString(R.string.dict_available_notification_description))
+ .setTicker(notificationTitle)
+ .setOngoing(false)
+ .setOnlyAlertOnce(true)
+ .setSmallIcon(R.drawable.ic_notify_dictionary)
+ .getNotification();
+ notificationManager.notify(DICT_AVAILABLE_NOTIFICATION_ID, notification);
+ }
+
+ /**
+ * 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, final boolean mayPrompt) {
+ 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;
+ }
+
+ if (mayPrompt
+ && DOWNLOAD_OVER_METERED_SETTING_UNKNOWN
+ == getDownloadOverMeteredSetting(context)) {
+ final ConnectivityManager cm =
+ (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
+ if (ConnectivityManagerCompatUtils.isActiveNetworkMetered(cm)) {
+ showDictionaryAvailableNotification(context, clientId, installCandidate);
+ 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();
+ actions.add(new ActionBatch.StartDownloadAction(clientId,
+ WordListMetadata.createFromContentValues(installCandidate), false));
+ 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: we know that from the mayPrompt argument.
+ if (mayPrompt) {
+ 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);
+ }
+ 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 List<WordListMetadata> currentMetadata =
+ MetadataHandler.getCurrentMetadata(context, clientId);
+ WordListMetadata wordList = MetadataHandler.findWordListById(currentMetadata, wordlistId);
+ if (null == wordList) return;
+ final ActionBatch actions = new ActionBatch();
+ if (MetadataDbHelper.STATUS_DISABLED == status
+ || MetadataDbHelper.STATUS_DELETING == status) {
+ actions.add(new ActionBatch.EnableAction(clientId, wordList));
+ } else if (MetadataDbHelper.STATUS_AVAILABLE == status) {
+ actions.add(new ActionBatch.StartDownloadAction(clientId, wordList,
+ allowDownloadOnMeteredData));
+ } 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 List<WordListMetadata> currentMetadata =
+ MetadataHandler.getCurrentMetadata(context, clientId);
+ final WordListMetadata wordList =
+ MetadataHandler.findWordListById(currentMetadata, wordlistId);
+ if (null == wordList) return;
+ final ActionBatch actions = new ActionBatch();
+ actions.add(new ActionBatch.DisableAction(clientId, wordList));
+ 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 Android 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 List<WordListMetadata> currentMetadata =
+ MetadataHandler.getCurrentMetadata(context, clientId);
+ final WordListMetadata wordList =
+ MetadataHandler.findWordListById(currentMetadata, wordlistId);
+ if (null == wordList) return;
+ final ActionBatch actions = new ActionBatch();
+ actions.add(new ActionBatch.DisableAction(clientId, wordList));
+ actions.add(new ActionBatch.StartDeleteAction(clientId, wordList));
+ 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 List<WordListMetadata> currentMetadata =
+ MetadataHandler.getCurrentMetadata(context, clientId);
+ final WordListMetadata wordList =
+ MetadataHandler.findWordListById(currentMetadata, wordlistId);
+ if (null == wordList) return;
+ final ActionBatch actions = new ActionBatch();
+ actions.add(new ActionBatch.FinishDeleteAction(clientId, wordList));
+ actions.execute(context, new LogProblemReporter(TAG));
+ signalNewDictionaryState(context);
+ }
+
+ /**
+ * Marks the word list with the passed id as broken.
+ *
+ * This effectively deletes the entry from the metadata. It doesn't prevent the same
+ * word list to be downloaded again at a later time if the same or a new version is
+ * available the next time we download the metadata.
+ *
+ * @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 broken.
+ * @param version the version of the word list to mark as deleted.
+ */
+ public static void markAsBroken(final Context context, final String clientId,
+ final String wordlistId, final int version) {
+ // TODO: do this on another thread to avoid blocking the UI.
+ MetadataDbHelper.deleteEntry(MetadataDbHelper.getDb(context, clientId),
+ wordlistId, version);
+ }
+}
diff --git a/java/src/com/android/inputmethod/dictionarypack/Utils.java b/java/src/com/android/inputmethod/dictionarypack/Utils.java
new file mode 100644
index 000000000..c4a42dbbf
--- /dev/null
+++ b/java/src/com/android/inputmethod/dictionarypack/Utils.java
@@ -0,0 +1,104 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.android.inputmethod.dictionarypack;
+
+import android.util.Log;
+
+/**
+ * A class for various utility methods, especially debugging.
+ */
+public final class Utils {
+ private final static String TAG = Utils.class.getSimpleName() + ":DEBUG --";
+ private final static boolean DEBUG = DictionaryProvider.DEBUG;
+
+ /**
+ * Calls .toString() on its non-null argument or returns "null"
+ * @param o the object to convert to a string
+ * @return the result of .toString() or null
+ */
+ public static String s(final Object o) {
+ return null == o ? "null" : o.toString();
+ }
+
+ /**
+ * Get the string representation of the current stack trace, for debugging purposes.
+ * @return a readable, carriage-return-separated string for the current stack trace.
+ */
+ public static String getStackTrace() {
+ final StringBuilder sb = new StringBuilder();
+ try {
+ throw new RuntimeException();
+ } catch (RuntimeException e) {
+ StackTraceElement[] frames = e.getStackTrace();
+ // Start at 1 because the first frame is here and we don't care about it
+ for (int j = 1; j < frames.length; ++j) {
+ sb.append(frames[j].toString() + "\n");
+ }
+ }
+ return sb.toString();
+ }
+
+ /**
+ * Get the stack trace contained in an exception as a human-readable string.
+ * @param e the exception
+ * @return the human-readable stack trace
+ */
+ public static String getStackTrace(final Exception e) {
+ final StringBuilder sb = new StringBuilder();
+ final StackTraceElement[] frames = e.getStackTrace();
+ for (int j = 0; j < frames.length; ++j) {
+ sb.append(frames[j].toString() + "\n");
+ }
+ return sb.toString();
+ }
+
+ /**
+ * Helper log method to ease null-checks and adding spaces.
+ *
+ * This sends all arguments to the log, separated by spaces. Any null argument is converted
+ * to the "null" string. It uses a very visible tag and log level for debugging purposes.
+ *
+ * @param args the stuff to send to the log
+ */
+ public static void l(final Object... args) {
+ if (!DEBUG) return;
+ final StringBuilder sb = new StringBuilder();
+ for (final Object o : args) {
+ sb.append(s(o).toString());
+ sb.append(" ");
+ }
+ Log.e(TAG, sb.toString());
+ }
+
+ /**
+ * Helper log method to put stuff in red.
+ *
+ * This does the same as #l but prints in red
+ *
+ * @param args the stuff to send to the log
+ */
+ public static void r(final Object... args) {
+ if (!DEBUG) return;
+ final StringBuilder sb = new StringBuilder("\u001B[31m");
+ for (final Object o : args) {
+ sb.append(s(o).toString());
+ sb.append(" ");
+ }
+ sb.append("\u001B[0m");
+ Log.e(TAG, sb.toString());
+ }
+}
diff --git a/java/src/com/android/inputmethod/dictionarypack/WordListMetadata.java b/java/src/com/android/inputmethod/dictionarypack/WordListMetadata.java
new file mode 100644
index 000000000..69bff9597
--- /dev/null
+++ b/java/src/com/android/inputmethod/dictionarypack/WordListMetadata.java
@@ -0,0 +1,122 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.android.inputmethod.dictionarypack;
+
+import android.content.ContentValues;
+
+/**
+ * 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 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
+
+ // 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 checksum, 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;
+ mChecksum = checksum;
+ 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(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 checksum = values.getAsString(MetadataDbHelper.CHECKSUM_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, checksum,
+ 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("\nChecksum : ").append(mChecksum);
+ 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/com/android/inputmethod/dictionarypack/WordListPreference.java b/java/src/com/android/inputmethod/dictionarypack/WordListPreference.java
new file mode 100644
index 000000000..0d923ae01
--- /dev/null
+++ b/java/src/com/android/inputmethod/dictionarypack/WordListPreference.java
@@ -0,0 +1,248 @@
+/**
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.android.inputmethod.dictionarypack;
+
+import android.app.Dialog;
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.preference.DialogPreference;
+import android.util.Log;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Button;
+
+import com.android.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 DialogPreference {
+ static final private 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.
+ static final private String NO_STATUS_MESSAGE = "";
+
+ /// Actions
+ static final private int ACTION_UNKNOWN = 0;
+ static final private int ACTION_ENABLE_DICT = 1;
+ static final private int ACTION_DISABLE_DICT = 2;
+ static final private int ACTION_DELETE_DICT = 3;
+
+ // Members
+ // The context to get resources
+ final Context mContext;
+ // The id of the client for which this preference is.
+ final String mClientId;
+ // The metadata word list id and version of this word list.
+ public final String mWordlistId;
+ public final int mVersion;
+ // The status
+ public int mStatus;
+
+ // Animation directions
+ static final private int ANIMATION_IN = 1;
+ static final private int ANIMATION_OUT = 2;
+
+ private static Button sLastClickedActionButton = null;
+ private final OnWordListPreferenceClick mPreferenceClickHandler =
+ new OnWordListPreferenceClick();
+ private final OnActionButtonClick mActionButtonClickHandler =
+ new OnActionButtonClick();
+
+ public WordListPreference(final Context context, final String clientId, final String wordlistId,
+ final int version, final Locale locale, final String description, final int status) {
+ super(context, null);
+ mContext = context;
+ mClientId = clientId;
+ mVersion = version;
+ mWordlistId = wordlistId;
+
+ setLayoutResource(R.layout.dictionary_line);
+
+ setTitle(description);
+ setStatus(status);
+ setKey(wordlistId);
+ }
+
+ private void setStatus(final int status) {
+ if (status == mStatus) return;
+ mStatus = status;
+ setSummary(getSummary(status));
+ // If we are currently displaying the dialog, we should update it, or at least
+ // dismiss it.
+ final Dialog dialog = getDialog();
+ if (null != dialog) {
+ dialog.dismiss();
+ }
+ }
+
+ private String getSummary(final int status) {
+ 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 Android Keyboard
+ // would only be confusing.
+ case MetadataDbHelper.STATUS_DELETING:
+ case MetadataDbHelper.STATUS_AVAILABLE:
+ return mContext.getString(R.string.dictionary_available);
+ case MetadataDbHelper.STATUS_DOWNLOADING:
+ return mContext.getString(R.string.dictionary_downloading);
+ case MetadataDbHelper.STATUS_INSTALLED:
+ return mContext.getString(R.string.dictionary_installed);
+ case MetadataDbHelper.STATUS_DISABLED:
+ return mContext.getString(R.string.dictionary_disabled);
+ default:
+ return NO_STATUS_MESSAGE;
+ }
+ }
+
+ private static final int sStatusActionList[][] = {
+ // MetadataDbHelper.STATUS_UNKNOWN
+ {},
+ // MetadataDbHelper.STATUS_AVAILABLE
+ { R.string.install_dict, ACTION_ENABLE_DICT },
+ // MetadataDbHelper.STATUS_DOWNLOADING
+ { R.string.cancel_download_dict, ACTION_DISABLE_DICT },
+ // MetadataDbHelper.STATUS_INSTALLED
+ { R.string.delete_dict, ACTION_DELETE_DICT },
+ // MetadataDbHelper.STATUS_DISABLED
+ { R.string.delete_dict, ACTION_DELETE_DICT },
+ // MetadataDbHelper.STATUS_DELETING
+ // We show 'install' because the file is supposed to be deleted.
+ // The user may reinstall it.
+ { R.string.install_dict, ACTION_ENABLE_DICT }
+ };
+
+ private CharSequence getButtonLabel(final int status) {
+ if (status >= sStatusActionList.length) {
+ Log.e(TAG, "Unknown status " + status);
+ return "";
+ }
+ return mContext.getString(sStatusActionList[status][0]);
+ }
+
+ private 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() {
+ SharedPreferences prefs = CommonPreferences.getCommonPreferences(mContext);
+ CommonPreferences.disable(prefs, mWordlistId);
+ UpdateHandler.markAsUnused(mContext, 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() {
+ SharedPreferences prefs = CommonPreferences.getCommonPreferences(mContext);
+ CommonPreferences.enable(prefs, mWordlistId);
+ // Explicit enabling by the user : allow downloading on metered data connection.
+ UpdateHandler.markAsUsed(mContext, 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 Android 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() {
+ SharedPreferences prefs = CommonPreferences.getCommonPreferences(mContext);
+ CommonPreferences.disable(prefs, mWordlistId);
+ setStatus(MetadataDbHelper.STATUS_DELETING);
+ UpdateHandler.markAsDeleting(mContext, mClientId, mWordlistId, mVersion, mStatus);
+ }
+
+ @Override
+ protected void onBindView(final View view) {
+ super.onBindView(view);
+ ((ViewGroup)view).setLayoutTransition(null);
+ final Button button = (Button)view.findViewById(R.id.wordlist_button);
+ button.setText(getButtonLabel(mStatus));
+ button.setVisibility(View.INVISIBLE);
+ button.setOnClickListener(mActionButtonClickHandler);
+ view.setOnClickListener(mPreferenceClickHandler);
+ }
+
+ private class OnWordListPreferenceClick implements View.OnClickListener {
+ @Override
+ public void onClick(final View v) {
+ final Button button = (Button)v.findViewById(R.id.wordlist_button);
+ if (null != sLastClickedActionButton) {
+ animateButton(sLastClickedActionButton, ANIMATION_OUT);
+ }
+ animateButton(button, ANIMATION_IN);
+ sLastClickedActionButton = button;
+ }
+ }
+
+ private void animateButton(final Button button, final int direction) {
+ final float outerX = ((View)button.getParent()).getWidth();
+ final float innerX = button.getX() - button.getTranslationX();
+ if (View.INVISIBLE == button.getVisibility()) {
+ button.setTranslationX(outerX - innerX);
+ button.setVisibility(View.VISIBLE);
+ }
+ if (ANIMATION_IN == direction) {
+ button.animate().translationX(0);
+ } else {
+ button.animate().translationX(outerX - innerX);
+ }
+ }
+
+ private class OnActionButtonClick implements View.OnClickListener {
+ @Override
+ public void onClick(final View v) {
+ 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");
+ }
+ }
+ }
+}
diff --git a/java/src/com/android/inputmethod/latin/BinaryDictionary.java b/java/src/com/android/inputmethod/latin/BinaryDictionary.java
index d369e2b47..7383862b1 100644
--- a/java/src/com/android/inputmethod/latin/BinaryDictionary.java
+++ b/java/src/com/android/inputmethod/latin/BinaryDictionary.java
@@ -19,6 +19,7 @@ package com.android.inputmethod.latin;
import android.text.TextUtils;
import android.util.SparseArray;
+import com.android.inputmethod.dictionarypack.DictionaryProvider;
import com.android.inputmethod.keyboard.ProximityInfo;
import com.android.inputmethod.latin.SuggestedWords.SuggestedWordInfo;
@@ -31,8 +32,7 @@ import java.util.Locale;
*/
public final class BinaryDictionary extends Dictionary {
private static final String TAG = BinaryDictionary.class.getSimpleName();
- public static final String DICTIONARY_PACK_AUTHORITY =
- "com.android.inputmethod.latin.dictionarypack";
+ public static final String DICTIONARY_PACK_AUTHORITY = DictionaryProvider.AUTHORITY;
// Must be equal to MAX_WORD_LENGTH in native/jni/src/defines.h
private static final int MAX_WORD_LENGTH = Constants.Dictionary.MAX_WORD_LENGTH;
diff --git a/java/src/com/android/inputmethod/latin/BinaryDictionaryGetter.java b/java/src/com/android/inputmethod/latin/BinaryDictionaryGetter.java
index f68e9f90b..294312843 100644
--- a/java/src/com/android/inputmethod/latin/BinaryDictionaryGetter.java
+++ b/java/src/com/android/inputmethod/latin/BinaryDictionaryGetter.java
@@ -96,18 +96,8 @@ final class BinaryDictionaryGetter {
private static final class DictPackSettings {
final SharedPreferences mDictPreferences;
public DictPackSettings(final Context context) {
- Context dictPackContext = null;
- try {
- final String dictPackName =
- context.getString(R.string.dictionary_pack_package_name);
- dictPackContext = context.createPackageContext(dictPackName, 0);
- } catch (NameNotFoundException e) {
- // The dictionary pack is not installed...
- // TODO: fallback on the built-in dict, see the TODO above
- Log.e(TAG, "Could not find a dictionary pack");
- }
- mDictPreferences = null == dictPackContext ? null
- : dictPackContext.getSharedPreferences(COMMON_PREFERENCES_NAME,
+ mDictPreferences = null == context ? null
+ : context.getSharedPreferences(COMMON_PREFERENCES_NAME,
Context.MODE_WORLD_READABLE | Context.MODE_MULTI_PROCESS);
}
public boolean isWordListActive(final String dictId) {
diff --git a/java/src/com/android/inputmethod/latin/DictionaryPackInstallBroadcastReceiver.java b/java/src/com/android/inputmethod/latin/DictionaryPackInstallBroadcastReceiver.java
index a8513ff45..d6c88910f 100644
--- a/java/src/com/android/inputmethod/latin/DictionaryPackInstallBroadcastReceiver.java
+++ b/java/src/com/android/inputmethod/latin/DictionaryPackInstallBroadcastReceiver.java
@@ -16,6 +16,8 @@
package com.android.inputmethod.latin;
+import com.android.inputmethod.dictionarypack.UpdateHandler;
+
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
@@ -34,7 +36,7 @@ public final class DictionaryPackInstallBroadcastReceiver extends BroadcastRecei
* The action of the intent for publishing that new dictionary data is available.
*/
/* package */ static final String NEW_DICTIONARY_INTENT_ACTION =
- "com.android.inputmethod.latin.dictionarypack.newdict";
+ UpdateHandler.NEW_DICTIONARY_INTENT_ACTION;
public DictionaryPackInstallBroadcastReceiver(final LatinIME service) {
mService = service;
diff --git a/java/src/com/android/inputmethod/latin/SettingsFragment.java b/java/src/com/android/inputmethod/latin/SettingsFragment.java
index 840829c24..fa17b4ffc 100644
--- a/java/src/com/android/inputmethod/latin/SettingsFragment.java
+++ b/java/src/com/android/inputmethod/latin/SettingsFragment.java
@@ -30,6 +30,7 @@ import android.preference.PreferenceGroup;
import android.preference.PreferenceScreen;
import android.view.inputmethod.InputMethodSubtype;
+import com.android.inputmethod.dictionarypack.DictionarySettingsActivity;
import com.android.inputmethod.latin.define.ProductionFlag;
import com.android.inputmethod.latin.setup.LauncherIconVisibilityManager;
import com.android.inputmethodcommon.InputMethodSettingsFragment;
@@ -146,6 +147,7 @@ public final class SettingsFragment extends InputMethodSettingsFragment
final PreferenceScreen dictionaryLink =
(PreferenceScreen) findPreference(Settings.PREF_CONFIGURE_DICTIONARIES_KEY);
final Intent intent = dictionaryLink.getIntent();
+ intent.setClassName(context.getPackageName(), DictionarySettingsActivity.class.getName());
final int number = context.getPackageManager().queryIntentActivities(intent, 0).size();
// TODO: The development-only-diagnostic version is not supported by the Dictionary Pack
// Service yet
diff --git a/java/src/com/android/inputmethod/latin/spellcheck/AndroidSpellCheckerSession.java b/java/src/com/android/inputmethod/latin/spellcheck/AndroidSpellCheckerSession.java
index 63f46b79e..9a1114f7f 100644
--- a/java/src/com/android/inputmethod/latin/spellcheck/AndroidSpellCheckerSession.java
+++ b/java/src/com/android/inputmethod/latin/spellcheck/AndroidSpellCheckerSession.java
@@ -16,6 +16,7 @@
package com.android.inputmethod.latin.spellcheck;
+import android.os.Binder;
import android.text.TextUtils;
import android.util.Log;
import android.view.textservice.SentenceSuggestionsInfo;
@@ -133,22 +134,27 @@ public final class AndroidSpellCheckerSession extends AndroidWordLevelSpellCheck
@Override
public SuggestionsInfo[] onGetSuggestionsMultiple(TextInfo[] textInfos,
int suggestionsLimit, boolean sequentialWords) {
- final int length = textInfos.length;
- final SuggestionsInfo[] retval = new SuggestionsInfo[length];
- for (int i = 0; i < length; ++i) {
- final String prevWord;
- if (sequentialWords && i > 0) {
+ long ident = Binder.clearCallingIdentity();
+ try {
+ final int length = textInfos.length;
+ final SuggestionsInfo[] retval = new SuggestionsInfo[length];
+ for (int i = 0; i < length; ++i) {
+ final String prevWord;
+ if (sequentialWords && i > 0) {
final String prevWordCandidate = textInfos[i - 1].getText();
// Note that an empty string would be used to indicate the initial word
// in the future.
prevWord = TextUtils.isEmpty(prevWordCandidate) ? null : prevWordCandidate;
- } else {
- prevWord = null;
+ } else {
+ prevWord = null;
+ }
+ retval[i] = onGetSuggestionsInternal(textInfos[i], prevWord, suggestionsLimit);
+ retval[i].setCookieAndSequence(textInfos[i].getCookie(),
+ textInfos[i].getSequence());
}
- retval[i] = onGetSuggestions(textInfos[i], prevWord, suggestionsLimit);
- retval[i].setCookieAndSequence(textInfos[i].getCookie(),
- textInfos[i].getSequence());
+ return retval;
+ } finally {
+ Binder.restoreCallingIdentity(ident);
}
- return retval;
}
}
diff --git a/java/src/com/android/inputmethod/latin/spellcheck/AndroidWordLevelSpellCheckerSession.java b/java/src/com/android/inputmethod/latin/spellcheck/AndroidWordLevelSpellCheckerSession.java
index cd3f9e442..4f86a3175 100644
--- a/java/src/com/android/inputmethod/latin/spellcheck/AndroidWordLevelSpellCheckerSession.java
+++ b/java/src/com/android/inputmethod/latin/spellcheck/AndroidWordLevelSpellCheckerSession.java
@@ -18,6 +18,7 @@ package com.android.inputmethod.latin.spellcheck;
import android.content.ContentResolver;
import android.database.ContentObserver;
+import android.os.Binder;
import android.provider.UserDictionary.Words;
import android.service.textservice.SpellCheckerService.Session;
import android.text.TextUtils;
@@ -234,13 +235,12 @@ public abstract class AndroidWordLevelSpellCheckerSession extends Session {
* corrections for the text passed as an argument. It may split or group words, and
* even perform grammatical analysis.
*/
- @Override
- public SuggestionsInfo onGetSuggestions(final TextInfo textInfo,
+ private SuggestionsInfo onGetSuggestionsInternal(final TextInfo textInfo,
final int suggestionsLimit) {
- return onGetSuggestions(textInfo, null, suggestionsLimit);
+ return onGetSuggestionsInternal(textInfo, null, suggestionsLimit);
}
- protected SuggestionsInfo onGetSuggestions(
+ protected SuggestionsInfo onGetSuggestionsInternal(
final TextInfo textInfo, final String prevWord, final int suggestionsLimit) {
try {
final String inText = textInfo.getText();
@@ -357,4 +357,22 @@ public abstract class AndroidWordLevelSpellCheckerSession extends Session {
}
}
}
+
+ /*
+ * The spell checker acts on its own behalf. That is needed, in particular, to be able to
+ * access the dictionary files, which the provider restricts to the identity of Latin IME.
+ * Since it's called externally by the application, the spell checker is using the identity
+ * of the application by default unless we clearCallingIdentity.
+ * That's what the following method does.
+ */
+ @Override
+ public SuggestionsInfo onGetSuggestions(final TextInfo textInfo,
+ final int suggestionsLimit) {
+ long ident = Binder.clearCallingIdentity();
+ try {
+ return onGetSuggestionsInternal(textInfo, suggestionsLimit);
+ } finally {
+ Binder.restoreCallingIdentity(ident);
+ }
+ }
}