diff options
author | 2013-03-15 19:00:51 +0900 | |
---|---|---|
committer | 2013-03-19 15:40:14 +0900 | |
commit | 0cc0544a2995c7eb54a830ae54db60af89d4073d (patch) | |
tree | 095745e7050c4a8f557f8967217f332874c78f04 /java/src/com/android/inputmethod | |
parent | 5b048292540b6fbbd8929a3622262f352245d464 (diff) | |
download | latinime-0cc0544a2995c7eb54a830ae54db60af89d4073d.tar.gz latinime-0cc0544a2995c7eb54a830ae54db60af89d4073d.tar.xz latinime-0cc0544a2995c7eb54a830ae54db60af89d4073d.zip |
Merge the dictionary pack in Latin IME.
Bug: 8161354
Change-Id: I17c23f56dd3bc2f27726556bf2c5a9d5520bd172
Diffstat (limited to 'java/src/com/android/inputmethod')
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); + } + } } |