diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/ActionBatch.java b/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/ActionBatch.java deleted file mode 100644 index 35cb70143..000000000 --- a/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/ActionBatch.java +++ /dev/null @@ -1,512 +0,0 @@ -/* - * Copyright (C) 2011 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not - * use this file except in compliance with the License. You may obtain a copy of - * the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations under - * the License. - */ - -package org.dslul.openboard.inputmethod.dictionarypack; - -import android.app.DownloadManager.Request; -import android.content.ContentValues; -import android.content.Context; -import android.content.res.Resources; -import android.database.sqlite.SQLiteDatabase; -import android.net.Uri; -import android.text.TextUtils; -import android.util.Log; - -import org.dslul.openboard.inputmethod.latin.BinaryDictionaryFileDumper; -import org.dslul.openboard.inputmethod.latin.R; -import org.dslul.openboard.inputmethod.latin.common.LocaleUtils; -import org.dslul.openboard.inputmethod.latin.utils.ApplicationUtils; -import org.dslul.openboard.inputmethod.latin.utils.DebugLogUtils; - -import java.util.LinkedList; -import java.util.Queue; - -/** - * Object representing an upgrade from one state to another. - * - * This implementation basically encapsulates a list of Runnable objects. In the future - * it may manage dependencies between them. Concretely, it does not use Runnable because the - * actions need an argument. - */ -/* - -The state of a word list follows the following scheme. - - | ^ - MakeAvailable | - | .------------Forget--------' - V | - STATUS_AVAILABLE <-------------------------. - | | -StartDownloadAction FinishDeleteAction - | | - V | -STATUS_DOWNLOADING EnableAction-- STATUS_DELETING - | | ^ -InstallAfterDownloadAction | | - | .---------------' StartDeleteAction - | | | - V V | - STATUS_INSTALLED <--EnableAction-- STATUS_DISABLED - --DisableAction--> - - It may also be possible that DisableAction or StartDeleteAction or - DownloadAction run when the file is still downloading. This cancels - the download and returns to STATUS_AVAILABLE. - Also, an UpdateDataAction may apply in any state. It does not affect - the state in any way (nor type, local filename, id or version) but - may update other attributes like description or remote filename. - - Forget is an DB maintenance action that removes the entry if it is not installed or disabled. - This happens when the word list information disappeared from the server, or when a new version - is available and we should forget about the old one. -*/ -public final class ActionBatch { - /** - * A piece of update. - * - * Action is basically like a Runnable that takes an argument. - */ - public interface Action { - /** - * Execute this action NOW. - * @param context the context to get system services, resources, databases - */ - void execute(final Context context); - } - - - /** - * An action that enables an existing word list. - */ - public static final class EnableAction implements Action { - static final String TAG = "DictionaryProvider:" + EnableAction.class.getSimpleName(); - private final String mClientId; - // The state to upgrade from. May not be null. - final WordListMetadata mWordList; - - public EnableAction(final String clientId, final WordListMetadata wordList) { - DebugLogUtils.l("New EnableAction for client ", clientId, " : ", wordList); - mClientId = clientId; - mWordList = wordList; - } - - @Override - public void execute(final Context context) { - if (null == mWordList) { - Log.e(TAG, "EnableAction with a null parameter!"); - return; - } - DebugLogUtils.l("Enabling word list"); - final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId); - final ContentValues values = MetadataDbHelper.getContentValuesByWordListId(db, - mWordList.mId, mWordList.mVersion); - final int status = values.getAsInteger(MetadataDbHelper.STATUS_COLUMN); - if (MetadataDbHelper.STATUS_DISABLED != status - && MetadataDbHelper.STATUS_DELETING != status) { - Log.e(TAG, "Unexpected state of the word list '" + mWordList.mId + " : " + status - + " for an enable action. Cancelling"); - return; - } - MetadataDbHelper.markEntryAsEnabled(db, mWordList.mId, mWordList.mVersion); - } - } - - /** - * An action that disables a word list. - */ - public static final class DisableAction implements Action { - static final String TAG = "DictionaryProvider:" + DisableAction.class.getSimpleName(); - private final String mClientId; - // The word list to disable. May not be null. - final WordListMetadata mWordList; - public DisableAction(final String clientId, final WordListMetadata wordlist) { - DebugLogUtils.l("New Disable action for client ", clientId, " : ", wordlist); - mClientId = clientId; - mWordList = wordlist; - } - - @Override - public void execute(final Context context) { - if (null == mWordList) { // This should never happen - Log.e(TAG, "DisableAction with a null word list!"); - return; - } - DebugLogUtils.l("Disabling word list : " + mWordList); - final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId); - final ContentValues values = MetadataDbHelper.getContentValuesByWordListId(db, - mWordList.mId, mWordList.mVersion); - final int status = values.getAsInteger(MetadataDbHelper.STATUS_COLUMN); - if (MetadataDbHelper.STATUS_INSTALLED == status) { - // Disabling an installed word list - MetadataDbHelper.markEntryAsDisabled(db, mWordList.mId, mWordList.mVersion); - } else { - if (MetadataDbHelper.STATUS_DOWNLOADING != status) { - Log.e(TAG, "Unexpected state of the word list '" + mWordList.mId + "' : " - + status + " for a disable action. Fall back to marking as available."); - } - // The word list is still downloading. Cancel the download and revert the - // word list status to "available". - } - } - } - - /** - * An action that makes a word list available. - */ - public static final class MakeAvailableAction implements Action { - static final String TAG = "DictionaryProvider:" + MakeAvailableAction.class.getSimpleName(); - private final String mClientId; - // The word list to make available. May not be null. - final WordListMetadata mWordList; - public MakeAvailableAction(final String clientId, final WordListMetadata wordlist) { - DebugLogUtils.l("New MakeAvailable action", clientId, " : ", wordlist); - mClientId = clientId; - mWordList = wordlist; - } - - @Override - public void execute(final Context context) { - if (null == mWordList) { // This should never happen - Log.e(TAG, "MakeAvailableAction with a null word list!"); - return; - } - final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId); - if (null != MetadataDbHelper.getContentValuesByWordListId(db, - mWordList.mId, mWordList.mVersion)) { - Log.e(TAG, "Unexpected state of the word list '" + mWordList.mId + "' " - + " for a makeavailable action. Marking as available anyway."); - } - DebugLogUtils.l("Making word list available : " + mWordList); - // If mLocalFilename is null, then it's a remote file that hasn't been downloaded - // yet, so we set the local filename to the empty string. - final ContentValues values = MetadataDbHelper.makeContentValues(0, - MetadataDbHelper.TYPE_BULK, MetadataDbHelper.STATUS_AVAILABLE, - mWordList.mId, mWordList.mLocale, mWordList.mDescription, - null == mWordList.mLocalFilename ? "" : mWordList.mLocalFilename, - mWordList.mRemoteFilename, mWordList.mLastUpdate, mWordList.mRawChecksum, - mWordList.mChecksum, mWordList.mRetryCount, mWordList.mFileSize, - mWordList.mVersion, mWordList.mFormatVersion); - PrivateLog.log("Insert 'available' record for " + mWordList.mDescription - + " and locale " + mWordList.mLocale); - db.insert(MetadataDbHelper.METADATA_TABLE_NAME, null, values); - } - } - - /** - * An action that marks a word list as pre-installed. - * - * This is almost the same as MakeAvailableAction, as it only inserts a line with parameters - * received from outside. - * Unlike MakeAvailableAction, the parameters are not received from a downloaded metadata file - * but from the client directly; it marks a word list as being "installed" and not "available". - * It also explicitly sets the filename to the empty string, so that we don't try to open - * it on our side. - */ - public static final class MarkPreInstalledAction implements Action { - static final String TAG = "DictionaryProvider:" - + MarkPreInstalledAction.class.getSimpleName(); - private final String mClientId; - // The word list to mark pre-installed. May not be null. - final WordListMetadata mWordList; - public MarkPreInstalledAction(final String clientId, final WordListMetadata wordlist) { - DebugLogUtils.l("New MarkPreInstalled action", clientId, " : ", wordlist); - mClientId = clientId; - mWordList = wordlist; - } - - @Override - public void execute(final Context context) { - if (null == mWordList) { // This should never happen - Log.e(TAG, "MarkPreInstalledAction with a null word list!"); - return; - } - final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId); - if (null != MetadataDbHelper.getContentValuesByWordListId(db, - mWordList.mId, mWordList.mVersion)) { - Log.e(TAG, "Unexpected state of the word list '" + mWordList.mId + "' " - + " for a markpreinstalled action. Marking as preinstalled anyway."); - } - DebugLogUtils.l("Marking word list preinstalled : " + mWordList); - // This word list is pre-installed : we don't have its file. We should reset - // the local file name to the empty string so that we don't try to open it - // accidentally. The remote filename may be set by the application if it so wishes. - final ContentValues values = MetadataDbHelper.makeContentValues(0, - MetadataDbHelper.TYPE_BULK, MetadataDbHelper.STATUS_INSTALLED, - mWordList.mId, mWordList.mLocale, mWordList.mDescription, - TextUtils.isEmpty(mWordList.mLocalFilename) ? "" : mWordList.mLocalFilename, - mWordList.mRemoteFilename, mWordList.mLastUpdate, - mWordList.mRawChecksum, mWordList.mChecksum, mWordList.mRetryCount, - mWordList.mFileSize, mWordList.mVersion, mWordList.mFormatVersion); - PrivateLog.log("Insert 'preinstalled' record for " + mWordList.mDescription - + " and locale " + mWordList.mLocale); - db.insert(MetadataDbHelper.METADATA_TABLE_NAME, null, values); - } - } - - /** - * An action that updates information about a word list - description, locale etc - */ - public static final class UpdateDataAction implements Action { - static final String TAG = "DictionaryProvider:" + UpdateDataAction.class.getSimpleName(); - private final String mClientId; - final WordListMetadata mWordList; - public UpdateDataAction(final String clientId, final WordListMetadata wordlist) { - DebugLogUtils.l("New UpdateData action for client ", clientId, " : ", wordlist); - mClientId = clientId; - mWordList = wordlist; - } - - @Override - public void execute(final Context context) { - if (null == mWordList) { // This should never happen - Log.e(TAG, "UpdateDataAction with a null word list!"); - return; - } - final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId); - ContentValues oldValues = MetadataDbHelper.getContentValuesByWordListId(db, - mWordList.mId, mWordList.mVersion); - if (null == oldValues) { - Log.e(TAG, "Trying to update data about a non-existing word list. Bailing out."); - return; - } - DebugLogUtils.l("Updating data about a word list : " + mWordList); - final ContentValues values = MetadataDbHelper.makeContentValues( - oldValues.getAsInteger(MetadataDbHelper.PENDINGID_COLUMN), - oldValues.getAsInteger(MetadataDbHelper.TYPE_COLUMN), - oldValues.getAsInteger(MetadataDbHelper.STATUS_COLUMN), - mWordList.mId, mWordList.mLocale, mWordList.mDescription, - oldValues.getAsString(MetadataDbHelper.LOCAL_FILENAME_COLUMN), - mWordList.mRemoteFilename, mWordList.mLastUpdate, mWordList.mRawChecksum, - mWordList.mChecksum, mWordList.mRetryCount, mWordList.mFileSize, - mWordList.mVersion, mWordList.mFormatVersion); - PrivateLog.log("Updating record for " + mWordList.mDescription - + " and locale " + mWordList.mLocale); - db.update(MetadataDbHelper.METADATA_TABLE_NAME, values, - MetadataDbHelper.WORDLISTID_COLUMN + " = ? AND " - + MetadataDbHelper.VERSION_COLUMN + " = ?", - new String[] { mWordList.mId, Integer.toString(mWordList.mVersion) }); - } - } - - /** - * An action that deletes the metadata about a word list if possible. - * - * This is triggered when a specific word list disappeared from the server, or when a fresher - * word list is available and the old one was not installed. - * If the word list has not been installed, it's possible to delete its associated metadata. - * Otherwise, the settings are retained so that the user can still administrate it. - */ - public static final class ForgetAction implements Action { - static final String TAG = "DictionaryProvider:" + ForgetAction.class.getSimpleName(); - private final String mClientId; - // The word list to remove. May not be null. - final WordListMetadata mWordList; - final boolean mHasNewerVersion; - public ForgetAction(final String clientId, final WordListMetadata wordlist, - final boolean hasNewerVersion) { - DebugLogUtils.l("New TryRemove action for client ", clientId, " : ", wordlist); - mClientId = clientId; - mWordList = wordlist; - mHasNewerVersion = hasNewerVersion; - } - - @Override - public void execute(final Context context) { - if (null == mWordList) { // This should never happen - Log.e(TAG, "TryRemoveAction with a null word list!"); - return; - } - DebugLogUtils.l("Trying to remove word list : " + mWordList); - final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId); - final ContentValues values = MetadataDbHelper.getContentValuesByWordListId(db, - mWordList.mId, mWordList.mVersion); - if (null == values) { - Log.e(TAG, "Trying to update the metadata of a non-existing wordlist. Cancelling."); - return; - } - final int status = values.getAsInteger(MetadataDbHelper.STATUS_COLUMN); - if (mHasNewerVersion && MetadataDbHelper.STATUS_AVAILABLE != status) { - // If we have a newer version of this word list, we should be here ONLY if it was - // not installed - else we should be upgrading it. - Log.e(TAG, "Unexpected status for forgetting a word list info : " + status - + ", removing URL to prevent re-download"); - } - if (MetadataDbHelper.STATUS_INSTALLED == status - || MetadataDbHelper.STATUS_DISABLED == status - || MetadataDbHelper.STATUS_DELETING == status) { - // If it is installed or disabled, we need to mark it as deleted so that LatinIME - // will remove it next time it enquires for dictionaries. - // If it is deleting and we don't have a new version, then we have to wait until - // LatinIME actually has deleted it before we can remove its metadata. - // In both cases, remove the URI from the database since it is not supposed to - // be accessible any more. - values.put(MetadataDbHelper.REMOTE_FILENAME_COLUMN, ""); - values.put(MetadataDbHelper.STATUS_COLUMN, MetadataDbHelper.STATUS_DELETING); - db.update(MetadataDbHelper.METADATA_TABLE_NAME, values, - MetadataDbHelper.WORDLISTID_COLUMN + " = ? AND " - + MetadataDbHelper.VERSION_COLUMN + " = ?", - new String[] { mWordList.mId, Integer.toString(mWordList.mVersion) }); - } else { - // If it's AVAILABLE or DOWNLOADING or even UNKNOWN, delete the entry. - db.delete(MetadataDbHelper.METADATA_TABLE_NAME, - MetadataDbHelper.WORDLISTID_COLUMN + " = ? AND " - + MetadataDbHelper.VERSION_COLUMN + " = ?", - new String[] { mWordList.mId, Integer.toString(mWordList.mVersion) }); - } - } - } - - /** - * An action that sets the word list for deletion as soon as possible. - * - * This is triggered when the user requests deletion of a word list. This will mark it as - * deleted in the database, and fire an intent for 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) { - DebugLogUtils.l("New StartDelete action for client ", clientId, " : ", wordlist); - mClientId = clientId; - mWordList = wordlist; - } - - @Override - public void execute(final Context context) { - if (null == mWordList) { // This should never happen - Log.e(TAG, "StartDeleteAction with a null word list!"); - return; - } - DebugLogUtils.l("Trying to delete word list : " + mWordList); - final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId); - final ContentValues values = MetadataDbHelper.getContentValuesByWordListId(db, - mWordList.mId, mWordList.mVersion); - if (null == values) { - Log.e(TAG, "Trying to set a non-existing wordlist for removal. Cancelling."); - return; - } - final int status = values.getAsInteger(MetadataDbHelper.STATUS_COLUMN); - if (MetadataDbHelper.STATUS_DISABLED != status) { - Log.e(TAG, "Unexpected status for deleting a word list info : " + status); - } - MetadataDbHelper.markEntryAsDeleting(db, mWordList.mId, mWordList.mVersion); - } - } - - /** - * An action that validates a word list as deleted. - * - * This will restore the word list as available if it still is, or remove the entry if - * it is not any more. - */ - public static final class FinishDeleteAction implements Action { - static final String TAG = "DictionaryProvider:" + FinishDeleteAction.class.getSimpleName(); - private final String mClientId; - // The word list to delete. May not be null. - final WordListMetadata mWordList; - public FinishDeleteAction(final String clientId, final WordListMetadata wordlist) { - DebugLogUtils.l("New FinishDelete action for client", clientId, " : ", wordlist); - mClientId = clientId; - mWordList = wordlist; - } - - @Override - public void execute(final Context context) { - if (null == mWordList) { // This should never happen - Log.e(TAG, "FinishDeleteAction with a null word list!"); - return; - } - DebugLogUtils.l("Trying to delete word list : " + mWordList); - final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId); - final ContentValues values = MetadataDbHelper.getContentValuesByWordListId(db, - mWordList.mId, mWordList.mVersion); - if (null == values) { - Log.e(TAG, "Trying to set a non-existing wordlist for removal. Cancelling."); - return; - } - final int status = values.getAsInteger(MetadataDbHelper.STATUS_COLUMN); - if (MetadataDbHelper.STATUS_DELETING != status) { - Log.e(TAG, "Unexpected status for finish-deleting a word list info : " + status); - } - final String remoteFilename = - values.getAsString(MetadataDbHelper.REMOTE_FILENAME_COLUMN); - // If there isn't a remote filename any more, then we don't know where to get the file - // from any more, so we remove the entry entirely. As a matter of fact, if the file was - // marked DELETING but disappeared from the metadata on the server, it ended up - // this way. - if (TextUtils.isEmpty(remoteFilename)) { - db.delete(MetadataDbHelper.METADATA_TABLE_NAME, - MetadataDbHelper.WORDLISTID_COLUMN + " = ? AND " - + MetadataDbHelper.VERSION_COLUMN + " = ?", - new String[] { mWordList.mId, Integer.toString(mWordList.mVersion) }); - } else { - MetadataDbHelper.markEntryAsAvailable(db, mWordList.mId, mWordList.mVersion); - } - } - } - - // An action batch consists of an ordered queue of Actions that can execute. - private final Queue mActions; - - public ActionBatch() { - mActions = new LinkedList<>(); - } - - public void add(final Action a) { - mActions.add(a); - } - - /** - * Append all the actions of another action batch. - * @param that the upgrade to merge into this one. - */ - public void append(final ActionBatch that) { - for (final Action a : that.mActions) { - add(a); - } - } - - /** - * Execute this batch. - * - * @param context the context for getting resources, databases, system services. - * @param reporter a Reporter to send errors to. - */ - public void execute(final Context context, final ProblemReporter reporter) { - DebugLogUtils.l("Executing a batch of actions"); - Queue 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/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/ActionBatch.kt b/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/ActionBatch.kt new file mode 100644 index 000000000..ed79bf3d5 --- /dev/null +++ b/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/ActionBatch.kt @@ -0,0 +1,517 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package org.dslul.openboard.inputmethod.dictionarypack + +import android.content.ContentValues +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import android.text.TextUtils +import android.util.Log +import org.dslul.openboard.inputmethod.dictionarypack.ActionBatch.DisableAction +import org.dslul.openboard.inputmethod.dictionarypack.ActionBatch.EnableAction +import org.dslul.openboard.inputmethod.dictionarypack.ActionBatch.FinishDeleteAction +import org.dslul.openboard.inputmethod.dictionarypack.ActionBatch.ForgetAction +import org.dslul.openboard.inputmethod.dictionarypack.ActionBatch.MakeAvailableAction +import org.dslul.openboard.inputmethod.dictionarypack.ActionBatch.MarkPreInstalledAction +import org.dslul.openboard.inputmethod.dictionarypack.ActionBatch.StartDeleteAction +import org.dslul.openboard.inputmethod.dictionarypack.ActionBatch.UpdateDataAction +import org.dslul.openboard.inputmethod.latin.utils.DebugLogUtils +import java.util.* + +/** + * 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. +*/ +class ActionBatch { + /** + * A piece of update. + * + * Action is basically like a Runnable that takes an argument. + */ + interface Action { + /** + * Execute this action NOW. + * @param context the context to get system services, resources, databases + */ + fun execute(context: Context?) + } + + /** + * An action that enables an existing word list. + */ + class EnableAction(clientId: String, wordList: WordListMetadata?) : Action { + private val mClientId: String + // The state to upgrade from. May not be null. + val mWordList: WordListMetadata? + + override fun execute(context: Context?) { + if (null == mWordList) { + Log.e(TAG, "EnableAction with a null parameter!") + return + } + DebugLogUtils.l("Enabling word list") + val db: SQLiteDatabase = MetadataDbHelper.Companion.getDb(context, mClientId) + val values: ContentValues = MetadataDbHelper.Companion.getContentValuesByWordListId(db, + mWordList.mId, mWordList.mVersion)!! + val status = values.getAsInteger(MetadataDbHelper.Companion.STATUS_COLUMN) + if (MetadataDbHelper.Companion.STATUS_DISABLED != status + && MetadataDbHelper.Companion.STATUS_DELETING != status) { + Log.e(TAG, "Unexpected state of the word list '" + mWordList.mId + " : " + status + + " for an enable action. Cancelling") + return + } + MetadataDbHelper.Companion.markEntryAsEnabled(db, mWordList.mId, mWordList.mVersion) + } + + companion object { + val TAG = "DictionaryProvider:" + EnableAction::class.java.simpleName + } + + init { + DebugLogUtils.l("New EnableAction for client ", clientId, " : ", wordList) + mClientId = clientId + mWordList = wordList + } + } + + /** + * An action that disables a word list. + */ + class DisableAction(clientId: String, wordlist: WordListMetadata?) : Action { + private val mClientId: String + // The word list to disable. May not be null. + val mWordList: WordListMetadata? + + override fun execute(context: Context?) { + if (null == mWordList) { // This should never happen + Log.e(TAG, "DisableAction with a null word list!") + return + } + DebugLogUtils.l("Disabling word list : $mWordList") + val db: SQLiteDatabase = MetadataDbHelper.Companion.getDb(context, mClientId) + val values: ContentValues = MetadataDbHelper.Companion.getContentValuesByWordListId(db, + mWordList.mId, mWordList.mVersion)!! + val status = values.getAsInteger(MetadataDbHelper.Companion.STATUS_COLUMN) + if (MetadataDbHelper.Companion.STATUS_INSTALLED == status) { // Disabling an installed word list + MetadataDbHelper.Companion.markEntryAsDisabled(db, mWordList.mId, mWordList.mVersion) + } else { + if (MetadataDbHelper.Companion.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". + } + } + + companion object { + val TAG = "DictionaryProvider:" + DisableAction::class.java.simpleName + } + + init { + DebugLogUtils.l("New Disable action for client ", clientId, " : ", wordlist) + mClientId = clientId + mWordList = wordlist + } + } + + /** + * An action that makes a word list available. + */ + class MakeAvailableAction(clientId: String, wordlist: WordListMetadata?) : Action { + private val mClientId: String + // The word list to make available. May not be null. + val mWordList: WordListMetadata? + + override fun execute(context: Context?) { + if (null == mWordList) { // This should never happen + Log.e(TAG, "MakeAvailableAction with a null word list!") + return + } + val db: SQLiteDatabase = MetadataDbHelper.Companion.getDb(context, mClientId) + if (null != MetadataDbHelper.Companion.getContentValuesByWordListId(db, + mWordList.mId, mWordList.mVersion)) { + Log.e(TAG, "Unexpected state of the word list '" + mWordList.mId + "' " + + " for a makeavailable action. Marking as available anyway.") + } + DebugLogUtils.l("Making word list available : $mWordList") + // If mLocalFilename is null, then it's a remote file that hasn't been downloaded +// yet, so we set the local filename to the empty string. + val values: ContentValues = MetadataDbHelper.Companion.makeContentValues(0, + MetadataDbHelper.Companion.TYPE_BULK, MetadataDbHelper.Companion.STATUS_AVAILABLE, + mWordList.mId, mWordList.mLocale, mWordList.mDescription, + mWordList.mLocalFilename ?: "", + mWordList.mRemoteFilename, mWordList.mLastUpdate, mWordList.mRawChecksum, + mWordList.mChecksum, mWordList.mRetryCount, mWordList.mFileSize, + mWordList.mVersion, mWordList.mFormatVersion) + PrivateLog.log("Insert 'available' record for " + mWordList.mDescription + + " and locale " + mWordList.mLocale) + db.insert(MetadataDbHelper.Companion.METADATA_TABLE_NAME, null, values) + } + + companion object { + val TAG = "DictionaryProvider:" + MakeAvailableAction::class.java.simpleName + } + + init { + DebugLogUtils.l("New MakeAvailable action", clientId, " : ", wordlist) + mClientId = clientId + mWordList = wordlist + } + } + + /** + * 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. + */ + class MarkPreInstalledAction(clientId: String?, wordlist: WordListMetadata?) : Action { + private val mClientId: String? + // The word list to mark pre-installed. May not be null. + val mWordList: WordListMetadata? + + override fun execute(context: Context?) { + if (null == mWordList) { // This should never happen + Log.e(TAG, "MarkPreInstalledAction with a null word list!") + return + } + val db: SQLiteDatabase = MetadataDbHelper.Companion.getDb(context, mClientId) + if (null != MetadataDbHelper.Companion.getContentValuesByWordListId(db, + mWordList.mId, mWordList.mVersion)) { + Log.e(TAG, "Unexpected state of the word list '" + mWordList.mId + "' " + + " for a markpreinstalled action. Marking as preinstalled anyway.") + } + DebugLogUtils.l("Marking word list preinstalled : $mWordList") + // This word list is pre-installed : we don't have its file. We should reset +// the local file name to the empty string so that we don't try to open it +// accidentally. The remote filename may be set by the application if it so wishes. + val values: ContentValues = MetadataDbHelper.Companion.makeContentValues(0, + MetadataDbHelper.Companion.TYPE_BULK, MetadataDbHelper.Companion.STATUS_INSTALLED, + mWordList.mId, mWordList.mLocale, mWordList.mDescription, + if (TextUtils.isEmpty(mWordList.mLocalFilename)) "" else mWordList.mLocalFilename, + mWordList.mRemoteFilename, mWordList.mLastUpdate, + mWordList.mRawChecksum, mWordList.mChecksum, mWordList.mRetryCount, + mWordList.mFileSize, mWordList.mVersion, mWordList.mFormatVersion) + PrivateLog.log("Insert 'preinstalled' record for " + mWordList.mDescription + + " and locale " + mWordList.mLocale) + db.insert(MetadataDbHelper.Companion.METADATA_TABLE_NAME, null, values) + } + + companion object { + val TAG = ("DictionaryProvider:" + + MarkPreInstalledAction::class.java.simpleName) + } + + init { + DebugLogUtils.l("New MarkPreInstalled action", clientId, " : ", wordlist) + mClientId = clientId + mWordList = wordlist + } + } + + /** + * An action that updates information about a word list - description, locale etc + */ + class UpdateDataAction(clientId: String, wordlist: WordListMetadata?) : Action { + private val mClientId: String + val mWordList: WordListMetadata? + override fun execute(context: Context?) { + if (null == mWordList) { // This should never happen + Log.e(TAG, "UpdateDataAction with a null word list!") + return + } + val db: SQLiteDatabase = MetadataDbHelper.Companion.getDb(context, mClientId) + val oldValues: ContentValues = MetadataDbHelper.Companion.getContentValuesByWordListId(db, + mWordList.mId, mWordList.mVersion)!! + if (null == oldValues) { + Log.e(TAG, "Trying to update data about a non-existing word list. Bailing out.") + return + } + DebugLogUtils.l("Updating data about a word list : $mWordList") + val values: ContentValues = MetadataDbHelper.Companion.makeContentValues( + oldValues.getAsInteger(MetadataDbHelper.Companion.PENDINGID_COLUMN), + oldValues.getAsInteger(MetadataDbHelper.Companion.TYPE_COLUMN), + oldValues.getAsInteger(MetadataDbHelper.Companion.STATUS_COLUMN), + mWordList.mId, mWordList.mLocale, mWordList.mDescription, + oldValues.getAsString(MetadataDbHelper.Companion.LOCAL_FILENAME_COLUMN), + mWordList.mRemoteFilename, mWordList.mLastUpdate, mWordList.mRawChecksum, + mWordList.mChecksum, mWordList.mRetryCount, mWordList.mFileSize, + mWordList.mVersion, mWordList.mFormatVersion) + PrivateLog.log("Updating record for " + mWordList.mDescription + + " and locale " + mWordList.mLocale) + db.update(MetadataDbHelper.Companion.METADATA_TABLE_NAME, values, + MetadataDbHelper.Companion.WORDLISTID_COLUMN + " = ? AND " + + MetadataDbHelper.Companion.VERSION_COLUMN + " = ?", arrayOf(mWordList.mId, Integer.toString(mWordList.mVersion))) + } + + companion object { + val TAG = "DictionaryProvider:" + UpdateDataAction::class.java.simpleName + } + + init { + DebugLogUtils.l("New UpdateData action for client ", clientId, " : ", wordlist) + mClientId = clientId + mWordList = wordlist + } + } + + /** + * 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. + */ + class ForgetAction(clientId: String, wordlist: WordListMetadata?, + hasNewerVersion: Boolean) : Action { + private val mClientId: String + // The word list to remove. May not be null. + val mWordList: WordListMetadata? + val mHasNewerVersion: Boolean + override fun execute(context: Context?) { + if (null == mWordList) { // This should never happen + Log.e(TAG, "TryRemoveAction with a null word list!") + return + } + DebugLogUtils.l("Trying to remove word list : $mWordList") + val db: SQLiteDatabase = MetadataDbHelper.Companion.getDb(context, mClientId) + val values: ContentValues = MetadataDbHelper.Companion.getContentValuesByWordListId(db, + mWordList.mId, mWordList.mVersion)!! + if (null == values) { + Log.e(TAG, "Trying to update the metadata of a non-existing wordlist. Cancelling.") + return + } + val status = values.getAsInteger(MetadataDbHelper.Companion.STATUS_COLUMN) + if (mHasNewerVersion && MetadataDbHelper.Companion.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.Companion.STATUS_INSTALLED == status || MetadataDbHelper.Companion.STATUS_DISABLED == status || MetadataDbHelper.Companion.STATUS_DELETING == status) { // If it is installed or disabled, we need to mark it as deleted so that LatinIME +// will remove it next time it enquires for dictionaries. +// If it is deleting and we don't have a new version, then we have to wait until +// LatinIME actually has deleted it before we can remove its metadata. +// In both cases, remove the URI from the database since it is not supposed to +// be accessible any more. + values.put(MetadataDbHelper.Companion.REMOTE_FILENAME_COLUMN, "") + values.put(MetadataDbHelper.Companion.STATUS_COLUMN, MetadataDbHelper.Companion.STATUS_DELETING) + db.update(MetadataDbHelper.Companion.METADATA_TABLE_NAME, values, + MetadataDbHelper.Companion.WORDLISTID_COLUMN + " = ? AND " + + MetadataDbHelper.Companion.VERSION_COLUMN + " = ?", arrayOf(mWordList.mId, Integer.toString(mWordList.mVersion))) + } else { // If it's AVAILABLE or DOWNLOADING or even UNKNOWN, delete the entry. + db.delete(MetadataDbHelper.Companion.METADATA_TABLE_NAME, + MetadataDbHelper.Companion.WORDLISTID_COLUMN + " = ? AND " + + MetadataDbHelper.Companion.VERSION_COLUMN + " = ?", arrayOf(mWordList.mId, Integer.toString(mWordList.mVersion))) + } + } + + companion object { + val TAG = "DictionaryProvider:" + ForgetAction::class.java.simpleName + } + + init { + DebugLogUtils.l("New TryRemove action for client ", clientId, " : ", wordlist) + mClientId = clientId + mWordList = wordlist + mHasNewerVersion = hasNewerVersion + } + } + + /** + * 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. + */ + class StartDeleteAction(clientId: String, wordlist: WordListMetadata?) : Action { + private val mClientId: String + // The word list to delete. May not be null. + val mWordList: WordListMetadata? + + override fun execute(context: Context?) { + if (null == mWordList) { // This should never happen + Log.e(TAG, "StartDeleteAction with a null word list!") + return + } + DebugLogUtils.l("Trying to delete word list : $mWordList") + val db: SQLiteDatabase = MetadataDbHelper.Companion.getDb(context, mClientId) + val values: ContentValues = MetadataDbHelper.Companion.getContentValuesByWordListId(db, + mWordList.mId, mWordList.mVersion)!! + if (null == values) { + Log.e(TAG, "Trying to set a non-existing wordlist for removal. Cancelling.") + return + } + val status = values.getAsInteger(MetadataDbHelper.Companion.STATUS_COLUMN) + if (MetadataDbHelper.Companion.STATUS_DISABLED != status) { + Log.e(TAG, "Unexpected status for deleting a word list info : $status") + } + MetadataDbHelper.Companion.markEntryAsDeleting(db, mWordList.mId, mWordList.mVersion) + } + + companion object { + val TAG = "DictionaryProvider:" + StartDeleteAction::class.java.simpleName + } + + init { + DebugLogUtils.l("New StartDelete action for client ", clientId, " : ", wordlist) + mClientId = clientId + mWordList = wordlist + } + } + + /** + * 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. + */ + class FinishDeleteAction(clientId: String, wordlist: WordListMetadata?) : Action { + private val mClientId: String + // The word list to delete. May not be null. + val mWordList: WordListMetadata? + + override fun execute(context: Context?) { + if (null == mWordList) { // This should never happen + Log.e(TAG, "FinishDeleteAction with a null word list!") + return + } + DebugLogUtils.l("Trying to delete word list : $mWordList") + val db: SQLiteDatabase = MetadataDbHelper.Companion.getDb(context, mClientId) + val values: ContentValues = MetadataDbHelper.Companion.getContentValuesByWordListId(db, + mWordList.mId, mWordList.mVersion)!! + if (null == values) { + Log.e(TAG, "Trying to set a non-existing wordlist for removal. Cancelling.") + return + } + val status = values.getAsInteger(MetadataDbHelper.Companion.STATUS_COLUMN) + if (MetadataDbHelper.Companion.STATUS_DELETING != status) { + Log.e(TAG, "Unexpected status for finish-deleting a word list info : $status") + } + val remoteFilename = values.getAsString(MetadataDbHelper.Companion.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.Companion.METADATA_TABLE_NAME, + MetadataDbHelper.Companion.WORDLISTID_COLUMN + " = ? AND " + + MetadataDbHelper.Companion.VERSION_COLUMN + " = ?", arrayOf(mWordList.mId, Integer.toString(mWordList.mVersion))) + } else { + MetadataDbHelper.Companion.markEntryAsAvailable(db, mWordList.mId, mWordList.mVersion) + } + } + + companion object { + val TAG = "DictionaryProvider:" + FinishDeleteAction::class.java.simpleName + } + + init { + DebugLogUtils.l("New FinishDelete action for client", clientId, " : ", wordlist) + mClientId = clientId + mWordList = wordlist + } + } + + // An action batch consists of an ordered queue of Actions that can execute. + private val mActions: Queue + + fun add(a: Action) { + mActions.add(a) + } + + /** + * Append all the actions of another action batch. + * @param that the upgrade to merge into this one. + */ + fun append(that: ActionBatch) { + for (a in 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. + */ + fun execute(context: Context?, reporter: ProblemReporter?) { + DebugLogUtils.l("Executing a batch of actions") + val remainingActions = mActions + while (!remainingActions.isEmpty()) { + val a = remainingActions.poll() + try { + a.execute(context) + } catch (e: Exception) { + reporter?.report(e) + } + } + } + + init { + mActions = LinkedList() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/AssetFileAddress.java b/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/AssetFileAddress.java deleted file mode 100644 index b64888c11..000000000 --- a/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/AssetFileAddress.java +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright (C) 2011 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not - * use this file except in compliance with the License. You may obtain a copy of - * the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations under - * the License. - */ - -package org.dslul.openboard.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/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/AssetFileAddress.kt b/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/AssetFileAddress.kt new file mode 100644 index 000000000..9d3c7d9c2 --- /dev/null +++ b/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/AssetFileAddress.kt @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package org.dslul.openboard.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. + */ +internal class AssetFileAddress(val mFilename: String, val mOffset: Long, val mLength: Long) { + + companion object { + /** + * 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. + */ + fun makeFromFileName(filename: String?): AssetFileAddress? { + if (null == filename) return null + val f = File(filename) + return if (!f.isFile) null else 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. + */ + fun makeFromFileNameAndOffset(filename: String?, + offset: Long, length: Long): AssetFileAddress? { + if (null == filename) return null + val f = File(filename) + return if (!f.isFile) null else AssetFileAddress(filename, offset, length) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/BadFormatException.java b/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/BadFormatException.kt similarity index 73% rename from app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/BadFormatException.java rename to app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/BadFormatException.kt index 92acb7b00..5a642c9b1 100644 --- a/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/BadFormatException.java +++ b/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/BadFormatException.kt @@ -13,18 +13,12 @@ * License for the specific language governing permissions and limitations under * the License. */ - -package org.dslul.openboard.inputmethod.dictionarypack; +package org.dslul.openboard.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); - } -} +class BadFormatException : Exception { + constructor() : super() {} + constructor(message: String?) : super(message) {} +} \ No newline at end of file diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/ButtonSwitcher.java b/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/ButtonSwitcher.java deleted file mode 100644 index b17e707aa..000000000 --- a/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/ButtonSwitcher.java +++ /dev/null @@ -1,170 +0,0 @@ -/** - * Copyright (C) 2013 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not - * use this file except in compliance with the License. You may obtain a copy - * of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations - * under the License. - */ - -package org.dslul.openboard.inputmethod.dictionarypack; - -import android.animation.Animator; -import android.animation.AnimatorListenerAdapter; -import android.content.Context; -import android.util.AttributeSet; -import android.view.View; -import android.view.ViewPropertyAnimator; -import android.widget.Button; -import android.widget.FrameLayout; - -import org.dslul.openboard.inputmethod.latin.R; - -/** - * A view that handles buttons inside it according to a status. - */ -public class ButtonSwitcher extends FrameLayout { - public static final int NOT_INITIALIZED = -1; - public static final int STATUS_NO_BUTTON = 0; - public static final int STATUS_INSTALL = 1; - public static final int STATUS_CANCEL = 2; - public static final int STATUS_DELETE = 3; - // One of the above - private int mStatus = NOT_INITIALIZED; - private int mAnimateToStatus = NOT_INITIALIZED; - - // Animation directions - public static final int ANIMATION_IN = 1; - public static final int ANIMATION_OUT = 2; - - private Button mInstallButton; - private Button mCancelButton; - private Button mDeleteButton; - private DictionaryListInterfaceState mInterfaceState; - private OnClickListener mOnClickListener; - - public ButtonSwitcher(Context context, AttributeSet attrs) { - super(context, attrs); - } - - public ButtonSwitcher(Context context, AttributeSet attrs, int defStyle) { - super(context, attrs, defStyle); - } - - public void reset(final DictionaryListInterfaceState interfaceState) { - mStatus = NOT_INITIALIZED; - mAnimateToStatus = NOT_INITIALIZED; - mInterfaceState = interfaceState; - } - - @Override - protected void onLayout(final boolean changed, final int left, final int top, final int right, - final int bottom) { - super.onLayout(changed, left, top, right, bottom); - mInstallButton = (Button)findViewById(R.id.dict_install_button); - mCancelButton = (Button)findViewById(R.id.dict_cancel_button); - mDeleteButton = (Button)findViewById(R.id.dict_delete_button); - setInternalOnClickListener(mOnClickListener); - setButtonPositionWithoutAnimation(mStatus); - if (mAnimateToStatus != NOT_INITIALIZED) { - // We have been asked to animate before we were ready, so we took a note of it. - // We are now ready: launch the animation. - animateButtonPosition(mStatus, mAnimateToStatus); - mStatus = mAnimateToStatus; - mAnimateToStatus = NOT_INITIALIZED; - } - } - - private Button getButton(final int status) { - switch(status) { - case STATUS_INSTALL: - return mInstallButton; - case STATUS_CANCEL: - return mCancelButton; - case STATUS_DELETE: - return mDeleteButton; - default: - return null; - } - } - - public void setStatusAndUpdateVisuals(final int status) { - if (mStatus == NOT_INITIALIZED) { - setButtonPositionWithoutAnimation(status); - mStatus = status; - } else { - if (null == mInstallButton) { - // We may come here before we have been layout. In this case we don't know our - // size yet so we can't start animations so we need to remember what animation to - // start once layout has gone through. - mAnimateToStatus = status; - } else { - animateButtonPosition(mStatus, status); - mStatus = status; - } - } - } - - private void setButtonPositionWithoutAnimation(final int status) { - // This may be called by setStatus() before the layout has come yet. - if (null == mInstallButton) return; - final int width = getWidth(); - // Set to out of the screen if that's not the currently displayed status - mInstallButton.setTranslationX(STATUS_INSTALL == status ? 0 : width); - mCancelButton.setTranslationX(STATUS_CANCEL == status ? 0 : width); - mDeleteButton.setTranslationX(STATUS_DELETE == status ? 0 : width); - } - - // The helper method for {@link AnimatorListenerAdapter}. - void animateButtonIfStatusIsEqual(final View newButton, final int newStatus) { - if (newStatus != mStatus) return; - animateButton(newButton, ANIMATION_IN); - } - - private void animateButtonPosition(final int oldStatus, final int newStatus) { - final View oldButton = getButton(oldStatus); - final View newButton = getButton(newStatus); - if (null != oldButton && null != newButton) { - // Transition between two buttons : animate out, then in - animateButton(oldButton, ANIMATION_OUT).setListener(new AnimatorListenerAdapter() { - @Override - public void onAnimationEnd(final Animator animation) { - animateButtonIfStatusIsEqual(newButton, newStatus); - } - }); - } else if (null != oldButton) { - animateButton(oldButton, ANIMATION_OUT); - } else if (null != newButton) { - animateButton(newButton, ANIMATION_IN); - } - } - - public void setInternalOnClickListener(final OnClickListener listener) { - mOnClickListener = listener; - if (null != mInstallButton) { - // Already laid out : do it now - mInstallButton.setOnClickListener(mOnClickListener); - mCancelButton.setOnClickListener(mOnClickListener); - mDeleteButton.setOnClickListener(mOnClickListener); - } - } - - private ViewPropertyAnimator animateButton(final View button, final int direction) { - final float outerX = getWidth(); - final float innerX = button.getX() - button.getTranslationX(); - mInterfaceState.removeFromCache((View)getParent()); - if (ANIMATION_IN == direction) { - button.setClickable(true); - return button.animate().translationX(0); - } - button.setClickable(false); - return button.animate().translationX(outerX - innerX); - } -} diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/ButtonSwitcher.kt b/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/ButtonSwitcher.kt new file mode 100644 index 000000000..5be47efe9 --- /dev/null +++ b/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/ButtonSwitcher.kt @@ -0,0 +1,150 @@ +/** + * Copyright (C) 2013 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy + * of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package org.dslul.openboard.inputmethod.dictionarypack + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.view.ViewPropertyAnimator +import android.widget.Button +import android.widget.FrameLayout +import org.dslul.openboard.inputmethod.latin.R + +/** + * A view that handles buttons inside it according to a status. + */ +class ButtonSwitcher : FrameLayout { + // One of the above + private var mStatus = NOT_INITIALIZED + private var mAnimateToStatus = NOT_INITIALIZED + private var mInstallButton: Button? = null + private var mCancelButton: Button? = null + private var mDeleteButton: Button? = null + private var mInterfaceState: DictionaryListInterfaceState? = null + private var mOnClickListener: OnClickListener? = null + + constructor(context: Context?, attrs: AttributeSet?) : super(context!!, attrs) {} + constructor(context: Context?, attrs: AttributeSet?, defStyle: Int) : super(context!!, attrs, defStyle) {} + + fun reset(interfaceState: DictionaryListInterfaceState?) { + mStatus = NOT_INITIALIZED + mAnimateToStatus = NOT_INITIALIZED + mInterfaceState = interfaceState + } + + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, + bottom: Int) { + super.onLayout(changed, left, top, right, bottom) + mInstallButton = findViewById(R.id.dict_install_button) as Button + mCancelButton = findViewById(R.id.dict_cancel_button) as Button + mDeleteButton = findViewById(R.id.dict_delete_button) as Button + setInternalOnClickListener(mOnClickListener) + setButtonPositionWithoutAnimation(mStatus) + if (mAnimateToStatus != NOT_INITIALIZED) { // We have been asked to animate before we were ready, so we took a note of it. +// We are now ready: launch the animation. + animateButtonPosition(mStatus, mAnimateToStatus) + mStatus = mAnimateToStatus + mAnimateToStatus = NOT_INITIALIZED + } + } + + private fun getButton(status: Int): Button? { + return when (status) { + STATUS_INSTALL -> mInstallButton + STATUS_CANCEL -> mCancelButton + STATUS_DELETE -> mDeleteButton + else -> null + } + } + + fun setStatusAndUpdateVisuals(status: Int) { + if (mStatus == NOT_INITIALIZED) { + setButtonPositionWithoutAnimation(status) + mStatus = status + } else { + if (null == mInstallButton) { // We may come here before we have been layout. In this case we don't know our +// size yet so we can't start animations so we need to remember what animation to +// start once layout has gone through. + mAnimateToStatus = status + } else { + animateButtonPosition(mStatus, status) + mStatus = status + } + } + } + + private fun setButtonPositionWithoutAnimation(status: Int) { // This may be called by setStatus() before the layout has come yet. + if (null == mInstallButton) return + val width = width + // Set to out of the screen if that's not the currently displayed status + mInstallButton!!.setTranslationX(if (STATUS_INSTALL == status) 0F else width.toFloat()) + mCancelButton!!.setTranslationX(if (STATUS_CANCEL == status) 0F else width.toFloat()) + mDeleteButton!!.setTranslationX(if (STATUS_DELETE == status) 0F else width.toFloat()) + } + + // The helper method for {@link AnimatorListenerAdapter}. + fun animateButtonIfStatusIsEqual(newButton: View, newStatus: Int) { + if (newStatus != mStatus) return + animateButton(newButton, ANIMATION_IN) + } + + private fun animateButtonPosition(oldStatus: Int, newStatus: Int) { + val oldButton: View? = getButton(oldStatus) + val newButton: View? = getButton(newStatus) + if (null != oldButton && null != newButton) { // Transition between two buttons : animate out, then in + animateButton(oldButton, ANIMATION_OUT).setListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + animateButtonIfStatusIsEqual(newButton, newStatus) + } + }) + } else oldButton?.let { animateButton(it, ANIMATION_OUT) } + ?: newButton?.let { animateButton(it, ANIMATION_IN) } + } + + fun setInternalOnClickListener(listener: OnClickListener?) { + mOnClickListener = listener + if (null != mInstallButton) { // Already laid out : do it now + mInstallButton!!.setOnClickListener(mOnClickListener) + mCancelButton!!.setOnClickListener(mOnClickListener) + mDeleteButton!!.setOnClickListener(mOnClickListener) + } + } + + private fun animateButton(button: View, direction: Int): ViewPropertyAnimator { + val outerX = width.toFloat() + val innerX = button.x - button.translationX + mInterfaceState!!.removeFromCache(parent as View) + if (ANIMATION_IN == direction) { + button.isClickable = true + return button.animate().translationX(0f) + } + button.isClickable = false + return button.animate().translationX(outerX - innerX) + } + + companion object { + const val NOT_INITIALIZED = -1 + const val STATUS_NO_BUTTON = 0 + const val STATUS_INSTALL = 1 + const val STATUS_CANCEL = 2 + const val STATUS_DELETE = 3 + // Animation directions + const val ANIMATION_IN = 1 + const val ANIMATION_OUT = 2 + } +} \ No newline at end of file diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/CommonPreferences.java b/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/CommonPreferences.java deleted file mode 100644 index 2c02fce29..000000000 --- a/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/CommonPreferences.java +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Copyright (C) 2011 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not - * use this file except in compliance with the License. You may obtain a copy - * of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations - * under the License. - */ - -package org.dslul.openboard.inputmethod.dictionarypack; - -import android.content.Context; -import android.content.SharedPreferences; - -public final class CommonPreferences { - private static final String COMMON_PREFERENCES_NAME = "LatinImeDictPrefs"; - - public static SharedPreferences getCommonPreferences(final Context context) { - return context.getSharedPreferences(COMMON_PREFERENCES_NAME, 0); - } - - public static void enable(final SharedPreferences pref, final String id) { - final SharedPreferences.Editor editor = pref.edit(); - editor.putBoolean(id, true); - editor.apply(); - } - - public static void disable(final SharedPreferences pref, final String id) { - final SharedPreferences.Editor editor = pref.edit(); - editor.putBoolean(id, false); - editor.apply(); - } -} diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/CommonPreferences.kt b/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/CommonPreferences.kt new file mode 100644 index 000000000..cee39ca63 --- /dev/null +++ b/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/CommonPreferences.kt @@ -0,0 +1,38 @@ +/** + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy + * of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package org.dslul.openboard.inputmethod.dictionarypack + +import android.content.Context +import android.content.SharedPreferences + +object CommonPreferences { + private const val COMMON_PREFERENCES_NAME = "LatinImeDictPrefs" + fun getCommonPreferences(context: Context): SharedPreferences { + return context.getSharedPreferences(COMMON_PREFERENCES_NAME, 0) + } + + fun enable(pref: SharedPreferences?, id: String?) { + val editor = pref!!.edit() + editor.putBoolean(id, true) + editor.apply() + } + + fun disable(pref: SharedPreferences?, id: String?) { + val editor = pref!!.edit() + editor.putBoolean(id, false) + editor.apply() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/DictionaryListInterfaceState.java b/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/DictionaryListInterfaceState.java deleted file mode 100644 index 8008c6f6d..000000000 --- a/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/DictionaryListInterfaceState.java +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Copyright (C) 2013 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not - * use this file except in compliance with the License. You may obtain a copy - * of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations - * under the License. - */ - -package org.dslul.openboard.inputmethod.dictionarypack; - -import android.view.View; - -import java.util.ArrayList; -import java.util.HashMap; - -/** - * Helper class to maintain the interface state of word list preferences. - * - * This is necessary because the views are created on-demand by calling code. There are many - * situations where views are renewed with little relation with user interaction. For example, - * when scrolling, the view is reused so it doesn't keep its state, which means we need to keep - * it separately. Also whenever the underlying dictionary list undergoes a change (for example, - * update the metadata, or finish downloading) the whole list has to be thrown out and recreated - * in case some dictionaries appeared, disappeared, changed states etc. - */ -public class DictionaryListInterfaceState { - static class State { - public boolean mOpen = false; - public int mStatus = MetadataDbHelper.STATUS_UNKNOWN; - } - - private HashMap mWordlistToState = new HashMap<>(); - private ArrayList mViewCache = new ArrayList<>(); - - public boolean isOpen(final String wordlistId) { - final State state = mWordlistToState.get(wordlistId); - if (null == state) return false; - return state.mOpen; - } - - public int getStatus(final String wordlistId) { - final State state = mWordlistToState.get(wordlistId); - if (null == state) return MetadataDbHelper.STATUS_UNKNOWN; - return state.mStatus; - } - - public void setOpen(final String wordlistId, final int status) { - final State newState; - final State state = mWordlistToState.get(wordlistId); - newState = null == state ? new State() : state; - newState.mOpen = true; - newState.mStatus = status; - mWordlistToState.put(wordlistId, newState); - } - - public void closeAll() { - for (final State state : mWordlistToState.values()) { - state.mOpen = false; - } - } - - public View findFirstOrphanedView() { - for (final View v : mViewCache) { - if (null == v.getParent()) return v; - } - return null; - } - - public View addToCacheAndReturnView(final View view) { - mViewCache.add(view); - return view; - } - - public void removeFromCache(final View view) { - mViewCache.remove(view); - } -} diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/DictionaryListInterfaceState.kt b/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/DictionaryListInterfaceState.kt new file mode 100644 index 000000000..a960ecccd --- /dev/null +++ b/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/DictionaryListInterfaceState.kt @@ -0,0 +1,79 @@ +/** + * Copyright (C) 2013 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy + * of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package org.dslul.openboard.inputmethod.dictionarypack + +import android.view.View +import java.util.* + +/** + * Helper class to maintain the interface state of word list preferences. + * + * This is necessary because the views are created on-demand by calling code. There are many + * situations where views are renewed with little relation with user interaction. For example, + * when scrolling, the view is reused so it doesn't keep its state, which means we need to keep + * it separately. Also whenever the underlying dictionary list undergoes a change (for example, + * update the metadata, or finish downloading) the whole list has to be thrown out and recreated + * in case some dictionaries appeared, disappeared, changed states etc. + */ +class DictionaryListInterfaceState { + internal class State { + var mOpen = false + var mStatus: Int = MetadataDbHelper.Companion.STATUS_UNKNOWN + } + + private val mWordlistToState = HashMap() + private val mViewCache = ArrayList() + fun isOpen(wordlistId: String?): Boolean { + val state = mWordlistToState[wordlistId] ?: return false + return state.mOpen + } + + fun getStatus(wordlistId: String?): Int { + val state = mWordlistToState[wordlistId] ?: return MetadataDbHelper.Companion.STATUS_UNKNOWN + return state.mStatus + } + + fun setOpen(wordlistId: String, status: Int) { + val newState: State + val state = mWordlistToState[wordlistId] + newState = state ?: State() + newState.mOpen = true + newState.mStatus = status + mWordlistToState[wordlistId] = newState + } + + fun closeAll() { + for (state in mWordlistToState.values) { + state.mOpen = false + } + } + + fun findFirstOrphanedView(): View? { + for (v in mViewCache) { + if (null == v.parent) return v + } + return null + } + + fun addToCacheAndReturnView(view: View): View { + mViewCache.add(view) + return view + } + + fun removeFromCache(view: View?) { + mViewCache.remove(view) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/DictionaryPackConstants.java b/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/DictionaryPackConstants.kt similarity index 60% rename from app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/DictionaryPackConstants.java rename to app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/DictionaryPackConstants.kt index 6e3883c2c..a1029d8f2 100644 --- a/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/DictionaryPackConstants.java +++ b/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/DictionaryPackConstants.kt @@ -13,8 +13,7 @@ * License for the specific language governing permissions and limitations under * the License. */ - -package org.dslul.openboard.inputmethod.dictionarypack; +package org.dslul.openboard.inputmethod.dictionarypack /** * A class to group constants for dictionary pack usage. @@ -23,50 +22,44 @@ package org.dslul.openboard.inputmethod.dictionarypack; * possible, as it's used to separate cleanly the keyboard code from the dictionary pack code; this * is needed in particular to cleanly compile regression tests. */ -public class DictionaryPackConstants { +object DictionaryPackConstants { /** * The root domain for the dictionary pack, upon which authorities and actions will append * their own distinctive strings. */ - private static final String DICTIONARY_DOMAIN = "org.dslul.openboard.inputmethod.dictionarypack.aosp"; - + private const val DICTIONARY_DOMAIN = "org.dslul.openboard.inputmethod.dictionarypack.aosp" /** * Authority for the ContentProvider protocol. */ - // TODO: find some way to factorize this string with the one in the resources - public static final String AUTHORITY = DICTIONARY_DOMAIN; - +// TODO: find some way to factorize this string with the one in the resources + const val AUTHORITY = DICTIONARY_DOMAIN /** * The action of the intent for publishing that new dictionary data is available. */ - // TODO: make this different across different packages. A suggested course of action is - // to use the package name inside this string. - // NOTE: The appended string should be uppercase like all other actions, but it's not for - // historical reasons. - public static final String NEW_DICTIONARY_INTENT_ACTION = DICTIONARY_DOMAIN + ".newdict"; - +// TODO: make this different across different packages. A suggested course of action is +// to use the package name inside this string. +// NOTE: The appended string should be uppercase like all other actions, but it's not for +// historical reasons. + const val NEW_DICTIONARY_INTENT_ACTION = "$DICTIONARY_DOMAIN.newdict" /** * The action of the intent sent by the dictionary pack to ask for a client to make * itself known. This is used when the settings activity is brought up for a client the * dictionary pack does not know about. */ - public static final String UNKNOWN_DICTIONARY_PROVIDER_CLIENT = DICTIONARY_DOMAIN - + ".UNKNOWN_CLIENT"; - + const val UNKNOWN_DICTIONARY_PROVIDER_CLIENT = (DICTIONARY_DOMAIN + + ".UNKNOWN_CLIENT") // In the above intents, the name of the string extra that contains the name of the client - // we want information about. - public static final String DICTIONARY_PROVIDER_CLIENT_EXTRA = "client"; - +// we want information about. + const val DICTIONARY_PROVIDER_CLIENT_EXTRA = "client" /** * The action of the intent to tell the dictionary provider to update now. */ - public static final String UPDATE_NOW_INTENT_ACTION = DICTIONARY_DOMAIN - + ".UPDATE_NOW"; - + const val UPDATE_NOW_INTENT_ACTION = (DICTIONARY_DOMAIN + + ".UPDATE_NOW") /** * The intent action to inform the dictionary provider to initialize the db * and update now. */ - public static final String INIT_AND_UPDATE_NOW_INTENT_ACTION = DICTIONARY_DOMAIN - + ".INIT_AND_UPDATE_NOW"; -} + const val INIT_AND_UPDATE_NOW_INTENT_ACTION = (DICTIONARY_DOMAIN + + ".INIT_AND_UPDATE_NOW") +} \ No newline at end of file diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/DictionaryProvider.java b/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/DictionaryProvider.java deleted file mode 100644 index b7ceb79b7..000000000 --- a/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/DictionaryProvider.java +++ /dev/null @@ -1,501 +0,0 @@ -/** - * Copyright (C) 2011 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not - * use this file except in compliance with the License. You may obtain a copy of - * the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations under - * the License. - */ - -package org.dslul.openboard.inputmethod.dictionarypack; - -import android.content.ContentProvider; -import android.content.ContentResolver; -import android.content.ContentValues; -import android.content.Context; -import android.content.UriMatcher; -import android.content.res.AssetFileDescriptor; -import android.database.AbstractCursor; -import android.database.Cursor; -import android.database.sqlite.SQLiteDatabase; -import android.net.Uri; -import android.os.ParcelFileDescriptor; -import android.text.TextUtils; -import android.util.Log; - -import org.dslul.openboard.inputmethod.latin.R; -import org.dslul.openboard.inputmethod.latin.common.LocaleUtils; -import org.dslul.openboard.inputmethod.latin.utils.DebugLogUtils; - -import java.io.File; -import java.io.FileNotFoundException; -import java.util.Collection; -import java.util.Collections; -import java.util.HashMap; - -/** - * Provider for dictionaries. - * - * This class is a ContentProvider exposing all available dictionary data as managed by - * the dictionary pack. - */ -public final class DictionaryProvider extends ContentProvider { - private static final String TAG = DictionaryProvider.class.getSimpleName(); - public static final boolean DEBUG = false; - - public static final Uri CONTENT_URI = - Uri.parse(ContentResolver.SCHEME_CONTENT + "://" + DictionaryPackConstants.AUTHORITY); - private static final String QUERY_PARAMETER_MAY_PROMPT_USER = "mayPrompt"; - private static final String QUERY_PARAMETER_TRUE = "true"; - private static final String QUERY_PARAMETER_DELETE_RESULT = "result"; - private static final String QUERY_PARAMETER_FAILURE = "failure"; - public static final String QUERY_PARAMETER_PROTOCOL_VERSION = "protocol"; - private static final int NO_MATCH = 0; - private static final int DICTIONARY_V1_WHOLE_LIST = 1; - private static final int DICTIONARY_V1_DICT_INFO = 2; - private static final int DICTIONARY_V2_METADATA = 3; - private static final int DICTIONARY_V2_WHOLE_LIST = 4; - private static final int DICTIONARY_V2_DICT_INFO = 5; - private static final int DICTIONARY_V2_DATAFILE = 6; - private static final UriMatcher sUriMatcherV1 = new UriMatcher(NO_MATCH); - private static final UriMatcher sUriMatcherV2 = new UriMatcher(NO_MATCH); - static - { - sUriMatcherV1.addURI(DictionaryPackConstants.AUTHORITY, "list", DICTIONARY_V1_WHOLE_LIST); - sUriMatcherV1.addURI(DictionaryPackConstants.AUTHORITY, "*", DICTIONARY_V1_DICT_INFO); - sUriMatcherV2.addURI(DictionaryPackConstants.AUTHORITY, "*/metadata", - DICTIONARY_V2_METADATA); - sUriMatcherV2.addURI(DictionaryPackConstants.AUTHORITY, "*/list", DICTIONARY_V2_WHOLE_LIST); - sUriMatcherV2.addURI(DictionaryPackConstants.AUTHORITY, "*/dict/*", - DICTIONARY_V2_DICT_INFO); - sUriMatcherV2.addURI(DictionaryPackConstants.AUTHORITY, "*/datafile/*", - DICTIONARY_V2_DATAFILE); - } - - // MIME types for dictionary and dictionary list, as required by ContentProvider contract. - public static final String DICT_LIST_MIME_TYPE = - "vnd.android.cursor.item/vnd.google.dictionarylist"; - public static final String DICT_DATAFILE_MIME_TYPE = - "vnd.android.cursor.item/vnd.google.dictionary"; - - public static final String ID_CATEGORY_SEPARATOR = ":"; - - private static final class WordListInfo { - public final String mId; - public final String mLocale; - public final String mRawChecksum; - public final int mMatchLevel; - public WordListInfo(final String id, final String locale, final String rawChecksum, - final int matchLevel) { - mId = id; - mLocale = locale; - mRawChecksum = rawChecksum; - mMatchLevel = matchLevel; - } - } - - /** - * A cursor for returning a list of file ids from a List of strings. - * - * This simulates only the necessary methods. It has no error handling to speak of, - * and does not support everything a database does, only a few select necessary methods. - */ - private static final class ResourcePathCursor extends AbstractCursor { - - // Column names for the cursor returned by this content provider. - static private final String[] columnNames = { MetadataDbHelper.WORDLISTID_COLUMN, - MetadataDbHelper.LOCALE_COLUMN, MetadataDbHelper.RAW_CHECKSUM_COLUMN }; - - // The list of word lists served by this provider that match the client request. - final WordListInfo[] mWordLists; - // Note : the cursor also uses mPos, which is defined in AbstractCursor. - - public ResourcePathCursor(final Collection wordLists) { - // Allocating a 0-size WordListInfo here allows the toArray() method - // to ensure we have a strongly-typed array. It's thrown out. That's - // what the documentation of #toArray says to do in order to get a - // new strongly typed array of the correct size. - mWordLists = wordLists.toArray(new WordListInfo[0]); - mPos = 0; - } - - @Override - public String[] getColumnNames() { - return columnNames; - } - - @Override - public int getCount() { - return mWordLists.length; - } - - @Override public double getDouble(int column) { return 0; } - @Override public float getFloat(int column) { return 0; } - @Override public int getInt(int column) { return 0; } - @Override public short getShort(int column) { return 0; } - @Override public long getLong(int column) { return 0; } - - @Override public String getString(final int column) { - switch (column) { - case 0: return mWordLists[mPos].mId; - case 1: return mWordLists[mPos].mLocale; - case 2: return mWordLists[mPos].mRawChecksum; - default : return null; - } - } - - @Override - public boolean isNull(final int column) { - if (mPos >= mWordLists.length) return true; - return column != 0; - } - } - - @Override - public boolean onCreate() { - return true; - } - - private static int matchUri(final Uri uri) { - int protocolVersion = 1; - final String protocolVersionArg = uri.getQueryParameter(QUERY_PARAMETER_PROTOCOL_VERSION); - if ("2".equals(protocolVersionArg)) protocolVersion = 2; - switch (protocolVersion) { - case 1: return sUriMatcherV1.match(uri); - case 2: return sUriMatcherV2.match(uri); - default: return NO_MATCH; - } - } - - private static String getClientId(final Uri uri) { - int protocolVersion = 1; - final String protocolVersionArg = uri.getQueryParameter(QUERY_PARAMETER_PROTOCOL_VERSION); - if ("2".equals(protocolVersionArg)) protocolVersion = 2; - switch (protocolVersion) { - case 1: return null; // In protocol 1, the client ID is always null. - case 2: return uri.getPathSegments().get(0); - default: return null; - } - } - - /** - * Returns the MIME type of the content associated with an Uri - * - * @see android.content.ContentProvider#getType(android.net.Uri) - * - * @param uri the URI of the content the type of which should be returned. - * @return the MIME type, or null if the URL is not recognized. - */ - @Override - public String getType(final Uri uri) { - PrivateLog.log("Asked for type of : " + uri); - final int match = matchUri(uri); - switch (match) { - case NO_MATCH: return null; - case DICTIONARY_V1_WHOLE_LIST: - case DICTIONARY_V1_DICT_INFO: - case DICTIONARY_V2_WHOLE_LIST: - case DICTIONARY_V2_DICT_INFO: return DICT_LIST_MIME_TYPE; - case DICTIONARY_V2_DATAFILE: return DICT_DATAFILE_MIME_TYPE; - default: return null; - } - } - - /** - * Query the provider for dictionary files. - * - * This version dispatches the query according to the protocol version found in the - * ?protocol= query parameter. If absent or not well-formed, it defaults to 1. - * @see android.content.ContentProvider#query(Uri, String[], String, String[], String) - * - * @param uri a content uri (see sUriMatcherV{1,2} at the top of this file for format) - * @param projection ignored. All columns are always returned. - * @param selection ignored. - * @param selectionArgs ignored. - * @param sortOrder ignored. The results are always returned in no particular order. - * @return a cursor matching the uri, or null if the URI was not recognized. - */ - @Override - public Cursor query(final Uri uri, final String[] projection, final String selection, - final String[] selectionArgs, final String sortOrder) { - DebugLogUtils.l("Uri =", uri); - PrivateLog.log("Query : " + uri); - final String clientId = getClientId(uri); - final int match = matchUri(uri); - switch (match) { - case DICTIONARY_V1_WHOLE_LIST: - case DICTIONARY_V2_WHOLE_LIST: - final Cursor c = MetadataDbHelper.queryDictionaries(getContext(), clientId); - DebugLogUtils.l("List of dictionaries with count", c.getCount()); - PrivateLog.log("Returned a list of " + c.getCount() + " items"); - return c; - case DICTIONARY_V2_DICT_INFO: - // In protocol version 2, we return null if the client is unknown. Otherwise - // we behave exactly like for protocol 1. - if (!MetadataDbHelper.isClientKnown(getContext(), clientId)) return null; - // Fall through - case DICTIONARY_V1_DICT_INFO: - final String locale = uri.getLastPathSegment(); - final Collection dictFiles = - getDictionaryWordListsForLocale(clientId, locale); - // TODO: pass clientId to the following function - if (null != dictFiles && dictFiles.size() > 0) { - PrivateLog.log("Returned " + dictFiles.size() + " files"); - return new ResourcePathCursor(dictFiles); - } - PrivateLog.log("No dictionary files for this URL"); - return new ResourcePathCursor(Collections.emptyList()); - // V2_METADATA and V2_DATAFILE are not supported for query() - default: - return null; - } - } - - /** - * Helper method to get the wordlist metadata associated with a wordlist ID. - * - * @param clientId the ID of the client - * @param wordlistId the ID of the wordlist for which to get the metadata. - * @return the metadata for this wordlist ID, or null if none could be found. - */ - private ContentValues getWordlistMetadataForWordlistId(final String clientId, - final String wordlistId) { - final Context context = getContext(); - if (TextUtils.isEmpty(wordlistId)) return null; - final SQLiteDatabase db = MetadataDbHelper.getDb(context, clientId); - return MetadataDbHelper.getInstalledOrDeletingWordListContentValuesByWordListId( - db, wordlistId); - } - - /** - * Opens an asset file for an URI. - * - * Called by {@link android.content.ContentResolver#openAssetFileDescriptor(Uri, String)} or - * {@link android.content.ContentResolver#openInputStream(Uri)} from a client requesting a - * dictionary. - * @see android.content.ContentProvider#openAssetFile(Uri, String) - * - * @param uri the URI the file is for. - * @param mode the mode to read the file. MUST be "r" for readonly. - * @return the descriptor, or null if the file is not found or if mode is not equals to "r". - */ - @Override - public AssetFileDescriptor openAssetFile(final Uri uri, final String mode) { - if (null == mode || !"r".equals(mode)) return null; - - final int match = matchUri(uri); - if (DICTIONARY_V1_DICT_INFO != match && DICTIONARY_V2_DATAFILE != match) { - // Unsupported URI for openAssetFile - Log.w(TAG, "Unsupported URI for openAssetFile : " + uri); - return null; - } - final String wordlistId = uri.getLastPathSegment(); - final String clientId = getClientId(uri); - final ContentValues wordList = getWordlistMetadataForWordlistId(clientId, wordlistId); - - if (null == wordList) return null; - - try { - final int status = wordList.getAsInteger(MetadataDbHelper.STATUS_COLUMN); - if (MetadataDbHelper.STATUS_DELETING == status) { - // This will return an empty file (R.raw.empty points at an empty dictionary) - // This is how we "delete" the files. It allows 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; - } - final String localFilename = - wordList.getAsString(MetadataDbHelper.LOCAL_FILENAME_COLUMN); - final File f = getContext().getFileStreamPath(localFilename); - final ParcelFileDescriptor pfd = - ParcelFileDescriptor.open(f, ParcelFileDescriptor.MODE_READ_ONLY); - return new AssetFileDescriptor(pfd, 0, pfd.getStatSize()); - } catch (FileNotFoundException e) { - // No file : fall through and return null - } - return null; - } - - /** - * Reads the metadata and returns the collection of dictionaries for a given locale. - * - * Word list IDs are expected to be in the form category:manual_id. This method - * will select only one word list for each category: the one with the most specific - * locale matching the locale specified in the URI. The manual id serves only to - * distinguish a word list from another for the purpose of updating, and is arbitrary - * but may not contain a colon. - * - * @param clientId the ID of the client requesting the list - * @param locale the locale for which we want the list, as a String - * @return a collection of ids. It is guaranteed to be non-null, but may be empty. - */ - private Collection getDictionaryWordListsForLocale(final String clientId, - final String locale) { - final Context context = getContext(); - final Cursor results = - MetadataDbHelper.queryInstalledOrDeletingOrAvailableDictionaryMetadata(context, - clientId); - if (null == results) { - return Collections.emptyList(); - } - try { - final HashMap dicts = new HashMap<>(); - final int idIndex = results.getColumnIndex(MetadataDbHelper.WORDLISTID_COLUMN); - final int localeIndex = results.getColumnIndex(MetadataDbHelper.LOCALE_COLUMN); - final int localFileNameIndex = - results.getColumnIndex(MetadataDbHelper.LOCAL_FILENAME_COLUMN); - final int rawChecksumIndex = - results.getColumnIndex(MetadataDbHelper.RAW_CHECKSUM_COLUMN); - final int statusIndex = results.getColumnIndex(MetadataDbHelper.STATUS_COLUMN); - if (results.moveToFirst()) { - do { - final String wordListId = results.getString(idIndex); - if (TextUtils.isEmpty(wordListId)) continue; - final String[] wordListIdArray = - TextUtils.split(wordListId, ID_CATEGORY_SEPARATOR); - final String wordListCategory; - // This is at the category:manual_id format. - wordListCategory = wordListIdArray[0]; - final String wordListLocale = results.getString(localeIndex); - final String wordListLocalFilename = results.getString(localFileNameIndex); - final String wordListRawChecksum = results.getString(rawChecksumIndex); - final int wordListStatus = results.getInt(statusIndex); - // Test the requested locale against this wordlist locale. The requested locale - // has to either match exactly or be more specific than the dictionary - a - // dictionary for "en" would match both a request for "en" or for "en_US", but a - // dictionary for "en_GB" would not match a request for "en_US". Thus if all - // three of "en" "en_US" and "en_GB" dictionaries are installed, a request for - // "en_US" would match "en" and "en_US", and a request for "en" only would only - // match the generic "en" dictionary. For more details, see the documentation - // for LocaleUtils#getMatchLevel. - final int matchLevel = LocaleUtils.getMatchLevel(wordListLocale, locale); - if (!LocaleUtils.isMatch(matchLevel)) { - // The locale of this wordlist does not match the required locale. - // Skip this wordlist and go to the next. - continue; - } - if (MetadataDbHelper.STATUS_INSTALLED == wordListStatus) { - // If the file does not exist, it has been deleted and the IME should - // already have it. Do not return it. However, this only applies if the - // word list is INSTALLED, for if it is DELETING we should return it always - // so that Android Keyboard can perform the actual deletion. - final File f = getContext().getFileStreamPath(wordListLocalFilename); - if (!f.isFile()) { - continue; - } - } - final WordListInfo currentBestMatch = dicts.get(wordListCategory); - if (null == currentBestMatch - || currentBestMatch.mMatchLevel < matchLevel) { - dicts.put(wordListCategory, new WordListInfo(wordListId, wordListLocale, - wordListRawChecksum, matchLevel)); - } - } while (results.moveToNext()); - } - return Collections.unmodifiableCollection(dicts.values()); - } finally { - results.close(); - } - } - - /** - * Deletes the file pointed by Uri, as returned by openAssetFile. - * - * @param uri the URI the file is for. - * @param selection ignored - * @param selectionArgs ignored - * @return the number of files deleted (0 or 1 in the current implementation) - * @see android.content.ContentProvider#delete(Uri, String, String[]) - */ - @Override - public int delete(final Uri uri, final String selection, final String[] selectionArgs) - throws UnsupportedOperationException { - final int match = matchUri(uri); - if (DICTIONARY_V1_DICT_INFO == match || DICTIONARY_V2_DATAFILE == match) { - return deleteDataFile(uri); - } - if (DICTIONARY_V2_METADATA == match) { - if (MetadataDbHelper.deleteClient(getContext(), getClientId(uri))) { - return 1; - } - return 0; - } - // Unsupported URI for delete - return 0; - } - - private int deleteDataFile(final Uri uri) { - final String wordlistId = uri.getLastPathSegment(); - final String clientId = getClientId(uri); - final ContentValues wordList = getWordlistMetadataForWordlistId(clientId, wordlistId); - if (null == wordList) { - return 0; - } - final int status = wordList.getAsInteger(MetadataDbHelper.STATUS_COLUMN); - final int version = wordList.getAsInteger(MetadataDbHelper.VERSION_COLUMN); - return 0; - } - - /** - * Insert data into the provider. May be either a metadata source URL or some dictionary info. - * - * @param uri the designated content URI. See sUriMatcherV{1,2} for available URIs. - * @param values the values to insert for this content uri - * @return the URI for the newly inserted item. May be null if arguments don't allow for insert - */ - @Override - public Uri insert(final Uri uri, final ContentValues values) - throws UnsupportedOperationException { - if (null == uri || null == values) return null; // Should never happen but let's be safe - PrivateLog.log("Insert, uri = " + uri.toString()); - final String clientId = getClientId(uri); - switch (matchUri(uri)) { - case DICTIONARY_V2_METADATA: - // The values should contain a valid client ID and a valid URI for the metadata. - // The client ID may not be null, nor may it be empty because the empty client ID - // is reserved for internal use. - // The metadata URI may not be null, but it may be empty if the client does not - // want the dictionary pack to update the metadata automatically. - MetadataDbHelper.updateClientInfo(getContext(), clientId, values); - break; - case DICTIONARY_V2_DICT_INFO: - try { - final WordListMetadata newDictionaryMetadata = - WordListMetadata.createFromContentValues( - MetadataDbHelper.completeWithDefaultValues(values)); - new ActionBatch.MarkPreInstalledAction(clientId, newDictionaryMetadata) - .execute(getContext()); - } catch (final BadFormatException e) { - Log.w(TAG, "Not enough information to insert this dictionary " + values, e); - } - break; - case DICTIONARY_V1_WHOLE_LIST: - case DICTIONARY_V1_DICT_INFO: - PrivateLog.log("Attempt to insert : " + uri); - throw new UnsupportedOperationException( - "Insertion in the dictionary is not supported in this version"); - } - return uri; - } - - /** - * Updating data is not supported, and will throw an exception. - * @see android.content.ContentProvider#update(Uri, ContentValues, String, String[]) - * @see android.content.ContentProvider#insert(Uri, ContentValues) - */ - @Override - public int update(final Uri uri, final ContentValues values, final String selection, - final String[] selectionArgs) throws UnsupportedOperationException { - PrivateLog.log("Attempt to update : " + uri); - throw new UnsupportedOperationException("Updating dictionary words is not supported"); - } -} diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/DictionaryProvider.kt b/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/DictionaryProvider.kt new file mode 100644 index 000000000..8d333534a --- /dev/null +++ b/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/DictionaryProvider.kt @@ -0,0 +1,455 @@ +/** + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package org.dslul.openboard.inputmethod.dictionarypack + +import android.content.ContentProvider +import android.content.ContentResolver +import android.content.ContentValues +import android.content.UriMatcher +import android.content.res.AssetFileDescriptor +import android.database.AbstractCursor +import android.database.Cursor +import android.database.sqlite.SQLiteDatabase +import android.net.Uri +import android.os.ParcelFileDescriptor +import android.text.TextUtils +import android.util.Log +import org.dslul.openboard.inputmethod.dictionarypack.ActionBatch.MarkPreInstalledAction +import org.dslul.openboard.inputmethod.latin.R +import org.dslul.openboard.inputmethod.latin.common.LocaleUtils +import org.dslul.openboard.inputmethod.latin.utils.DebugLogUtils +import java.io.FileNotFoundException +import java.util.* + +/** + * Provider for dictionaries. + * + * This class is a ContentProvider exposing all available dictionary data as managed by + * the dictionary pack. + */ +class DictionaryProvider : ContentProvider() { + companion object { + private val TAG = DictionaryProvider::class.java.simpleName + const val DEBUG = false + val CONTENT_URI = Uri.parse(ContentResolver.SCHEME_CONTENT + "://" + DictionaryPackConstants.AUTHORITY) + private const val QUERY_PARAMETER_MAY_PROMPT_USER = "mayPrompt" + private const val QUERY_PARAMETER_TRUE = "true" + private const val QUERY_PARAMETER_DELETE_RESULT = "result" + private const val QUERY_PARAMETER_FAILURE = "failure" + const val QUERY_PARAMETER_PROTOCOL_VERSION = "protocol" + private const val NO_MATCH = 0 + private const val DICTIONARY_V1_WHOLE_LIST = 1 + private const val DICTIONARY_V1_DICT_INFO = 2 + private const val DICTIONARY_V2_METADATA = 3 + private const val DICTIONARY_V2_WHOLE_LIST = 4 + private const val DICTIONARY_V2_DICT_INFO = 5 + private const val DICTIONARY_V2_DATAFILE = 6 + private val sUriMatcherV1 = UriMatcher(NO_MATCH) + private val sUriMatcherV2 = UriMatcher(NO_MATCH) + // MIME types for dictionary and dictionary list, as required by ContentProvider contract. + const val DICT_LIST_MIME_TYPE = "vnd.android.cursor.item/vnd.google.dictionarylist" + const val DICT_DATAFILE_MIME_TYPE = "vnd.android.cursor.item/vnd.google.dictionary" + const val ID_CATEGORY_SEPARATOR = ":" + private fun matchUri(uri: Uri): Int { + var protocolVersion = 1 + val protocolVersionArg = uri.getQueryParameter(QUERY_PARAMETER_PROTOCOL_VERSION) + if ("2" == protocolVersionArg) protocolVersion = 2 + return when (protocolVersion) { + 1 -> sUriMatcherV1.match(uri) + 2 -> sUriMatcherV2.match(uri) + else -> NO_MATCH + } + } + + private fun getClientId(uri: Uri): String? { + var protocolVersion = 1 + val protocolVersionArg = uri.getQueryParameter(QUERY_PARAMETER_PROTOCOL_VERSION) + if ("2" == protocolVersionArg) protocolVersion = 2 + return when (protocolVersion) { + 1 -> null // In protocol 1, the client ID is always null. + 2 -> uri.pathSegments[0] + else -> null + } + } + + init { + sUriMatcherV1.addURI(DictionaryPackConstants.AUTHORITY, "list", DICTIONARY_V1_WHOLE_LIST) + sUriMatcherV1.addURI(DictionaryPackConstants.AUTHORITY, "*", DICTIONARY_V1_DICT_INFO) + sUriMatcherV2.addURI(DictionaryPackConstants.AUTHORITY, "*/metadata", + DICTIONARY_V2_METADATA) + sUriMatcherV2.addURI(DictionaryPackConstants.AUTHORITY, "*/list", DICTIONARY_V2_WHOLE_LIST) + sUriMatcherV2.addURI(DictionaryPackConstants.AUTHORITY, "*/dict/*", + DICTIONARY_V2_DICT_INFO) + sUriMatcherV2.addURI(DictionaryPackConstants.AUTHORITY, "*/datafile/*", + DICTIONARY_V2_DATAFILE) + } + } + + private class WordListInfo(val mId: String, val mLocale: String, val mRawChecksum: String, + val mMatchLevel: Int) + + /** + * 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 class ResourcePathCursor(wordLists: Collection) : AbstractCursor() { + // The list of word lists served by this provider that match the client request. + val mWordLists: Array + + override fun getColumnNames(): Array { + return Companion.columnNames + } + + override fun getCount(): Int { + return mWordLists.size + } + + override fun getDouble(column: Int): Double { + return 0.0 + } + + override fun getFloat(column: Int): Float { + return 0F + } + + override fun getInt(column: Int): Int { + return 0 + } + + override fun getShort(column: Int): Short { + return 0 + } + + override fun getLong(column: Int): Long { + return 0 + } + + override fun getString(column: Int): String? { + return when (column) { + 0 -> mWordLists[mPos].mId + 1 -> mWordLists[mPos].mLocale + 2 -> mWordLists[mPos].mRawChecksum + else -> null + } + } + + override fun isNull(column: Int): Boolean { + return if (mPos >= mWordLists.size) true else column != 0 + } + + companion object { + // Column names for the cursor returned by this content provider. + private val columnNames = arrayOf(MetadataDbHelper.Companion.WORDLISTID_COLUMN, + MetadataDbHelper.Companion.LOCALE_COLUMN, MetadataDbHelper.Companion.RAW_CHECKSUM_COLUMN) + } + + // Note : the cursor also uses mPos, which is defined in AbstractCursor. + init { // 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.toTypedArray() + mPos = 0 + } + } + + override fun onCreate(): Boolean { + return true + } + + /** + * Returns the MIME type of the content associated with an Uri + * + * @see android.content.ContentProvider.getType + * @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 fun getType(uri: Uri): String? { + PrivateLog.log("Asked for type of : $uri") + val match = matchUri(uri) + return when (match) { + NO_MATCH -> null + DICTIONARY_V1_WHOLE_LIST, DICTIONARY_V1_DICT_INFO, DICTIONARY_V2_WHOLE_LIST, DICTIONARY_V2_DICT_INFO -> DICT_LIST_MIME_TYPE + DICTIONARY_V2_DATAFILE -> DICT_DATAFILE_MIME_TYPE + else -> 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 + * @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 fun query(uri: Uri, projection: Array?, selection: String?, + selectionArgs: Array?, sortOrder: String?): Cursor? { + DebugLogUtils.l("Uri =", uri) + PrivateLog.log("Query : $uri") + val clientId = getClientId(uri) + val match = matchUri(uri) + return when (match) { + DICTIONARY_V1_WHOLE_LIST, DICTIONARY_V2_WHOLE_LIST -> { + val c: Cursor = MetadataDbHelper.Companion.queryDictionaries(context, clientId) + DebugLogUtils.l("List of dictionaries with count", c.count) + PrivateLog.log("Returned a list of " + c.count + " items") + c + } + 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.Companion.isClientKnown(context, clientId)) return null + val locale = uri.lastPathSegment + val dictFiles = getDictionaryWordListsForLocale(clientId, locale) + // TODO: pass clientId to the following function + if (null != dictFiles && dictFiles.size > 0) { + PrivateLog.log("Returned " + dictFiles.size + " files") + return ResourcePathCursor(dictFiles) + } + PrivateLog.log("No dictionary files for this URL") + ResourcePathCursor(emptyList()) + } + DICTIONARY_V1_DICT_INFO -> { + val locale = uri.lastPathSegment + val dictFiles = getDictionaryWordListsForLocale(clientId, locale) + if (null != dictFiles && dictFiles.size > 0) { + PrivateLog.log("Returned " + dictFiles.size + " files") + return ResourcePathCursor(dictFiles) + } + PrivateLog.log("No dictionary files for this URL") + ResourcePathCursor(emptyList()) + } + else -> 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 fun getWordlistMetadataForWordlistId(clientId: String?, + wordlistId: String?): ContentValues? { + val context = context + if (TextUtils.isEmpty(wordlistId)) return null + val db: SQLiteDatabase = MetadataDbHelper.Companion.getDb(context, clientId) + return MetadataDbHelper.Companion.getInstalledOrDeletingWordListContentValuesByWordListId( + db, wordlistId) + } + + /** + * Opens an asset file for an URI. + * + * Called by [android.content.ContentResolver.openAssetFileDescriptor] or + * [android.content.ContentResolver.openInputStream] from a client requesting a + * dictionary. + * @see android.content.ContentProvider.openAssetFile + * @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 fun openAssetFile(uri: Uri, mode: String): AssetFileDescriptor? { + if (null == mode || "r" != mode) return null + val 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 + } + val wordlistId = uri.lastPathSegment + val clientId = getClientId(uri) + val wordList = getWordlistMetadataForWordlistId(clientId, wordlistId) ?: return null + try { + val status = wordList.getAsInteger(MetadataDbHelper.Companion.STATUS_COLUMN) + if (MetadataDbHelper.Companion.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. + return context!!.resources.openRawResourceFd( + R.raw.empty) + } + val localFilename = wordList.getAsString(MetadataDbHelper.Companion.LOCAL_FILENAME_COLUMN) + val f = context!!.getFileStreamPath(localFilename) + val pfd = ParcelFileDescriptor.open(f, ParcelFileDescriptor.MODE_READ_ONLY) + return AssetFileDescriptor(pfd, 0, pfd.statSize) + } catch (e: FileNotFoundException) { // No file : fall through and return null + } + return null + } + + /** + * Reads the metadata and returns the collection of dictionaries for a given locale. + * + * Word list IDs are expected to be in the form category:manual_id. This method + * will select only one word list for each category: the one with the most specific + * locale matching the locale specified in the URI. The manual id serves only to + * distinguish a word list from another for the purpose of updating, and is arbitrary + * but may not contain a colon. + * + * @param clientId the ID of the client requesting the list + * @param locale the locale for which we want the list, as a String + * @return a collection of ids. It is guaranteed to be non-null, but may be empty. + */ + private fun getDictionaryWordListsForLocale(clientId: String?, + locale: String?): Collection { + val context = context + val results: Cursor = MetadataDbHelper.Companion.queryInstalledOrDeletingOrAvailableDictionaryMetadata(context, + clientId) + ?: return emptyList() + return try { + val dicts = HashMap() + val idIndex = results.getColumnIndex(MetadataDbHelper.Companion.WORDLISTID_COLUMN) + val localeIndex = results.getColumnIndex(MetadataDbHelper.Companion.LOCALE_COLUMN) + val localFileNameIndex = results.getColumnIndex(MetadataDbHelper.Companion.LOCAL_FILENAME_COLUMN) + val rawChecksumIndex = results.getColumnIndex(MetadataDbHelper.Companion.RAW_CHECKSUM_COLUMN) + val statusIndex = results.getColumnIndex(MetadataDbHelper.Companion.STATUS_COLUMN) + if (results.moveToFirst()) { + do { + val wordListId = results.getString(idIndex) + if (TextUtils.isEmpty(wordListId)) continue + val wordListIdArray = TextUtils.split(wordListId, ID_CATEGORY_SEPARATOR) + val wordListCategory: String + // This is at the category:manual_id format. + wordListCategory = wordListIdArray[0] + val wordListLocale = results.getString(localeIndex) + val wordListLocalFilename = results.getString(localFileNameIndex) + val wordListRawChecksum = results.getString(rawChecksumIndex) + val 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. + val 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.Companion.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. + val f = getContext()!!.getFileStreamPath(wordListLocalFilename) + if (!f.isFile) { + continue + } + } + val currentBestMatch = dicts[wordListCategory] + if (null == currentBestMatch + || currentBestMatch.mMatchLevel < matchLevel) { + dicts[wordListCategory] = WordListInfo(wordListId, wordListLocale, + wordListRawChecksum, matchLevel) + } + } while (results.moveToNext()) + } + Collections.unmodifiableCollection(dicts.values) + } finally { + results.close() + } + } + + /** + * Deletes the file pointed by Uri, as returned by openAssetFile. + * + * @param uri the URI the file is for. + * @param selection ignored + * @param selectionArgs ignored + * @return the number of files deleted (0 or 1 in the current implementation) + * @see android.content.ContentProvider.delete + */ + @Throws(UnsupportedOperationException::class) + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int { + val match = matchUri(uri) + if (DICTIONARY_V1_DICT_INFO == match || DICTIONARY_V2_DATAFILE == match) { + return deleteDataFile(uri) + } + return if (DICTIONARY_V2_METADATA == match) { + if (MetadataDbHelper.Companion.deleteClient(context, getClientId(uri))) { + 1 + } else 0 + } else 0 + // Unsupported URI for delete + } + + private fun deleteDataFile(uri: Uri): Int { + val wordlistId = uri.lastPathSegment + val clientId = getClientId(uri) + val wordList = getWordlistMetadataForWordlistId(clientId, wordlistId) ?: return 0 + val status = wordList.getAsInteger(MetadataDbHelper.Companion.STATUS_COLUMN) + val version = wordList.getAsInteger(MetadataDbHelper.Companion.VERSION_COLUMN) + 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 + */ + @Throws(UnsupportedOperationException::class) + override fun insert(uri: Uri, values: ContentValues?): Uri? { + if (null == uri || null == values) return null // Should never happen but let's be safe + PrivateLog.log("Insert, uri = $uri") + val clientId = getClientId(uri) + when (matchUri(uri)) { + 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.Companion.updateClientInfo(context, clientId, values) + DICTIONARY_V2_DICT_INFO -> try { + val newDictionaryMetadata: WordListMetadata = WordListMetadata.Companion.createFromContentValues( + MetadataDbHelper.Companion.completeWithDefaultValues(values)) + MarkPreInstalledAction(clientId, newDictionaryMetadata) + .execute(context) + } catch (e: BadFormatException) { + Log.w(TAG, "Not enough information to insert this dictionary $values", e) + } + DICTIONARY_V1_WHOLE_LIST, DICTIONARY_V1_DICT_INFO -> { + PrivateLog.log("Attempt to insert : $uri") + throw 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 + * @see android.content.ContentProvider.insert + */ + @Throws(UnsupportedOperationException::class) + override fun update(uri: Uri, values: ContentValues?, selection: String?, + selectionArgs: Array?): Int { + PrivateLog.log("Attempt to update : $uri") + throw UnsupportedOperationException("Updating dictionary words is not supported") + } +} \ No newline at end of file diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/DictionarySettingsActivity.java b/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/DictionarySettingsActivity.java deleted file mode 100644 index 1d95c522e..000000000 --- a/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/DictionarySettingsActivity.java +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Copyright (C) 2011 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not - * use this file except in compliance with the License. You may obtain a copy - * of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations - * under the License. - */ - -package org.dslul.openboard.inputmethod.dictionarypack; - -import org.dslul.openboard.inputmethod.latin.utils.FragmentUtils; - -import android.annotation.TargetApi; -import android.content.Intent; -import android.os.Build; -import android.os.Bundle; -import android.preference.PreferenceActivity; - -/** - * Preference screen. - */ -public final class DictionarySettingsActivity extends PreferenceActivity { - private static final String DEFAULT_FRAGMENT = DictionarySettingsFragment.class.getName(); - - @Override - protected void onCreate(final Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - } - - @Override - public Intent getIntent() { - final Intent modIntent = new Intent(super.getIntent()); - modIntent.putExtra(EXTRA_SHOW_FRAGMENT, DEFAULT_FRAGMENT); - modIntent.putExtra(EXTRA_NO_HEADERS, true); - // Important note : the original intent should contain a String extra with the key - // DictionarySettingsFragment.DICT_SETTINGS_FRAGMENT_CLIENT_ID_ARGUMENT so that the - // fragment can know who the client is. - return modIntent; - } - - @TargetApi(Build.VERSION_CODES.KITKAT) - @Override - public boolean isValidFragment(String fragmentName) { - return FragmentUtils.isValidFragment(fragmentName); - } -} diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/DictionarySettingsActivity.kt b/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/DictionarySettingsActivity.kt new file mode 100644 index 000000000..a5ee716b5 --- /dev/null +++ b/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/DictionarySettingsActivity.kt @@ -0,0 +1,48 @@ +/** + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy + * of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package org.dslul.openboard.inputmethod.dictionarypack + +import android.annotation.TargetApi +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.preference.PreferenceActivity +import org.dslul.openboard.inputmethod.latin.utils.FragmentUtils + +/** + * Preference screen. + */ +class DictionarySettingsActivity : PreferenceActivity() { + + override fun getIntent(): Intent { + val modIntent = Intent(super.getIntent()) + modIntent.putExtra(EXTRA_SHOW_FRAGMENT, DEFAULT_FRAGMENT) + modIntent.putExtra(EXTRA_NO_HEADERS, true) + // Important note : the original intent should contain a String extra with the key +// DictionarySettingsFragment.DICT_SETTINGS_FRAGMENT_CLIENT_ID_ARGUMENT so that the +// fragment can know who the client is. + return modIntent + } + + @TargetApi(Build.VERSION_CODES.KITKAT) + public override fun isValidFragment(fragmentName: String): Boolean { + return FragmentUtils.isValidFragment(fragmentName) + } + + companion object { + private val DEFAULT_FRAGMENT = DictionarySettingsFragment::class.java.name + } +} \ No newline at end of file diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/DictionarySettingsFragment.java b/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/DictionarySettingsFragment.java deleted file mode 100644 index 7ed924fbb..000000000 --- a/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/DictionarySettingsFragment.java +++ /dev/null @@ -1,282 +0,0 @@ -/** - * Copyright (C) 2011 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not - * use this file except in compliance with the License. You may obtain a copy - * of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations - * under the License. - */ - -package org.dslul.openboard.inputmethod.dictionarypack; - -import org.dslul.openboard.inputmethod.latin.common.LocaleUtils; - -import android.app.Activity; -import android.content.BroadcastReceiver; -import android.content.ContentResolver; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.database.Cursor; -import android.net.ConnectivityManager; -import android.net.NetworkInfo; -import android.net.Uri; -import android.os.AsyncTask; -import android.os.Bundle; -import android.preference.Preference; -import android.preference.PreferenceFragment; -import android.preference.PreferenceGroup; -import android.text.TextUtils; -import android.util.Log; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; -import android.view.animation.AnimationUtils; - -import org.dslul.openboard.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 { - private static final String TAG = DictionarySettingsFragment.class.getSimpleName(); - - static final private String DICT_LIST_ID = "list"; - static final public String DICT_SETTINGS_FRAGMENT_CLIENT_ID_ARGUMENT = "clientId"; - - static final private int MENU_UPDATE_NOW = Menu.FIRST; - - private View mLoadingView; - private String mClientId; - //private ConnectivityManager mConnectivityManager; - private MenuItem mUpdateNowMenu; - private boolean mChangedSettings; - private DictionaryListInterfaceState mDictionaryListInterfaceState = - new DictionaryListInterfaceState(); - // never null - private TreeMap mCurrentPreferenceMap = new TreeMap<>(); - - /** - * 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 onResume() { - super.onResume(); - mChangedSettings = false; - final Activity activity = getActivity(); - final IntentFilter filter = new IntentFilter(); - - new Thread("onResume") { - @Override - public void run() { - if (!MetadataDbHelper.isClientKnown(activity, mClientId)) { - Log.i(TAG, "Unknown dictionary pack client: " + mClientId - + ". Requesting info."); - final Intent unknownClientBroadcast = - new Intent(DictionaryPackConstants.UNKNOWN_DICTIONARY_PROVIDER_CLIENT); - unknownClientBroadcast.putExtra( - DictionaryPackConstants.DICTIONARY_PROVIDER_CLIENT_EXTRA, mClientId); - activity.sendBroadcast(unknownClientBroadcast); - } - } - }.start(); - } - - @Override - public void onPause() { - super.onPause(); - final Activity activity = getActivity(); - if (mChangedSettings) { - final Intent newDictBroadcast = - new Intent(DictionaryPackConstants.NEW_DICTIONARY_INTENT_ACTION); - activity.sendBroadcast(newDictBroadcast); - mChangedSettings = false; - } - } - - 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; - } - - void refreshInterface() { - final Activity activity = getActivity(); - if (null == activity) return; - final PreferenceGroup prefScreen = getPreferenceScreen(); - final Collection prefList = - createInstalledDictSettingsCollection(mClientId); - - activity.runOnUiThread(new Runnable() { - @Override - public void run() { - // TODO: display this somewhere - // if (0 != lastUpdate) mUpdateNowPreference.setSummary(updateNowSummary); - removeAnyDictSettings(prefScreen); - int i = 0; - for (Preference preference : prefList) { - preference.setOrder(i++); - prefScreen.addPreference(preference); - } - } - }); - } - - private static Preference createErrorMessage(final Activity activity, final int messageResource) { - final Preference message = new Preference(activity); - message.setTitle(messageResource); - message.setEnabled(false); - return message; - } - - static void removeAnyDictSettings(final PreferenceGroup prefGroup) { - for (int i = prefGroup.getPreferenceCount() - 1; i >= 0; --i) { - prefGroup.removePreference(prefGroup.getPreference(i)); - } - } - - /** - * Creates a WordListPreference list to be added to the screen. - * - * This method only creates the preferences but does not add them. - * Thus, it can be called on another thread. - * - * @param clientId the id of the client for which we want to display the dictionary list - * @return A collection of preferences ready to add to the interface. - */ - private Collection createInstalledDictSettingsCollection( - final String clientId) { - // This will directly contact the DictionaryProvider and request the list exactly like - // any regular client would do. - // Considering the respective value of the respective constants used here for each path, - // segment, the url generated by this is of the form (assuming "clientId" as a clientId) - // content://org.dslul.openboard.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 result = new ArrayList<>(); - result.add(createErrorMessage(activity, R.string.cannot_connect_to_dict_service)); - return result; - } - try { - if (!cursor.moveToFirst()) { - final ArrayList result = new ArrayList<>(); - result.add(createErrorMessage(activity, R.string.no_dictionaries_available)); - return result; - } - final String systemLocaleString = Locale.getDefault().toString(); - final TreeMap prefMap = new TreeMap<>(); - final int idIndex = cursor.getColumnIndex(MetadataDbHelper.WORDLISTID_COLUMN); - final int versionIndex = cursor.getColumnIndex(MetadataDbHelper.VERSION_COLUMN); - final int localeIndex = cursor.getColumnIndex(MetadataDbHelper.LOCALE_COLUMN); - final int descriptionIndex = cursor.getColumnIndex(MetadataDbHelper.DESCRIPTION_COLUMN); - final int statusIndex = cursor.getColumnIndex(MetadataDbHelper.STATUS_COLUMN); - final int filesizeIndex = cursor.getColumnIndex(MetadataDbHelper.FILESIZE_COLUMN); - do { - final String wordlistId = cursor.getString(idIndex); - final int version = cursor.getInt(versionIndex); - final String localeString = cursor.getString(localeIndex); - final Locale locale = new Locale(localeString); - final String description = cursor.getString(descriptionIndex); - final int status = cursor.getInt(statusIndex); - final int matchLevel = LocaleUtils.getMatchLevel(systemLocaleString, localeString); - final String matchLevelString = LocaleUtils.getMatchLevelSortedString(matchLevel); - final int filesize = cursor.getInt(filesizeIndex); - // The key is sorted in lexicographic order, according to the match level, then - // the description. - final String key = matchLevelString + "." + description + "." + wordlistId; - final WordListPreference existingPref = prefMap.get(key); - if (null == existingPref || existingPref.hasPriorityOver(status)) { - final WordListPreference oldPreference = mCurrentPreferenceMap.get(key); - final WordListPreference pref; - if (null != oldPreference - && oldPreference.mVersion == version - && oldPreference.hasStatus(status) - && oldPreference.mLocale.equals(locale)) { - // If the old preference has all the new attributes, reuse it. Ideally, - // we should reuse the old pref even if its status is different and call - // setStatus here, but setStatus calls Preference#setSummary() which - // needs to be done on the UI thread and we're not on the UI thread - // here. We could do all this work on the UI thread, but in this case - // it's probably lighter to stay on a background thread and throw this - // old preference out. - pref = oldPreference; - } else { - // Otherwise, discard it and create a new one instead. - // TODO: when the status is different from the old one, we need to - // animate the old one out before animating the new one in. - pref = new WordListPreference(activity, mDictionaryListInterfaceState, - mClientId, wordlistId, version, locale, description, status, - filesize); - } - prefMap.put(key, pref); - } - } while (cursor.moveToNext()); - mCurrentPreferenceMap = prefMap; - return prefMap.values(); - } finally { - cursor.close(); - } - } - -} diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/DictionarySettingsFragment.kt b/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/DictionarySettingsFragment.kt new file mode 100644 index 000000000..ca5e76bbd --- /dev/null +++ b/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/DictionarySettingsFragment.kt @@ -0,0 +1,234 @@ +/** + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy + * of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package org.dslul.openboard.inputmethod.dictionarypack + +import android.app.Activity +import android.content.ContentResolver +import android.content.Intent +import android.content.IntentFilter +import android.net.Uri +import android.os.Bundle +import android.preference.Preference +import android.preference.PreferenceFragment +import android.preference.PreferenceGroup +import android.util.Log +import android.view.* +import org.dslul.openboard.inputmethod.latin.R +import org.dslul.openboard.inputmethod.latin.common.LocaleUtils +import java.util.* + +/** + * Preference screen. + */ +class DictionarySettingsFragment +/** + * Empty constructor for fragment generation. + */ + : PreferenceFragment() { + private var mLoadingView: View? = null + private var mClientId: String? = null + //private ConnectivityManager mConnectivityManager; + private val mUpdateNowMenu: MenuItem? = null + private var mChangedSettings = false + private val mDictionaryListInterfaceState = DictionaryListInterfaceState() + // never null + private var mCurrentPreferenceMap = TreeMap() + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle?): View? { + val v = inflater.inflate(R.layout.loading_page, container, true) + mLoadingView = v.findViewById(R.id.loading_container) + return super.onCreateView(inflater, container, savedInstanceState) + } + + override fun onActivityCreated(savedInstanceState: Bundle?) { + super.onActivityCreated(savedInstanceState) + val activity = activity + mClientId = activity.intent.getStringExtra(DICT_SETTINGS_FRAGMENT_CLIENT_ID_ARGUMENT) + /*mConnectivityManager = + (ConnectivityManager)activity.getSystemService(Context.CONNECTIVITY_SERVICE);*/addPreferencesFromResource(R.xml.dictionary_settings) + refreshInterface() + setHasOptionsMenu(true) + } + + override fun onResume() { + super.onResume() + mChangedSettings = false + val activity = activity + val filter = IntentFilter() + object : Thread("onResume") { + override fun run() { + if (!MetadataDbHelper.Companion.isClientKnown(activity, mClientId)) { + Log.i(TAG, "Unknown dictionary pack client: " + mClientId + + ". Requesting info.") + val unknownClientBroadcast = Intent(DictionaryPackConstants.UNKNOWN_DICTIONARY_PROVIDER_CLIENT) + unknownClientBroadcast.putExtra( + DictionaryPackConstants.DICTIONARY_PROVIDER_CLIENT_EXTRA, mClientId) + activity.sendBroadcast(unknownClientBroadcast) + } + } + }.start() + } + + override fun onPause() { + super.onPause() + val activity = activity + if (mChangedSettings) { + val newDictBroadcast = Intent(DictionaryPackConstants.NEW_DICTIONARY_INTENT_ACTION) + activity.sendBroadcast(newDictBroadcast) + mChangedSettings = false + } + } + + private fun findWordListPreference(id: String): WordListPreference? { + val prefScreen: PreferenceGroup? = preferenceScreen + if (null == prefScreen) { + Log.e(TAG, "Could not find the preference group") + return null + } + for (i in prefScreen.preferenceCount - 1 downTo 0) { + val pref = prefScreen.getPreference(i) + if (pref is WordListPreference) { + val wlPref = pref + if (id == wlPref.mWordlistId) { + return wlPref + } + } + } + Log.e(TAG, "Could not find the preference for a word list id $id") + return null + } + + fun refreshInterface() { + val activity = activity ?: return + val prefScreen: PreferenceGroup = preferenceScreen + val prefList = createInstalledDictSettingsCollection(mClientId) + activity.runOnUiThread { + // TODO: display this somewhere +// if (0 != lastUpdate) mUpdateNowPreference.setSummary(updateNowSummary); + removeAnyDictSettings(prefScreen) + var i = 0 + for (preference in prefList) { + preference.order = i++ + prefScreen.addPreference(preference) + } + } + } + + /** + * 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 fun createInstalledDictSettingsCollection( + clientId: String?): Collection { // This will directly contact the DictionaryProvider and request the list exactly like +// any regular client would do. +// Considering the respective value of the respective constants used here for each path, +// segment, the url generated by this is of the form (assuming "clientId" as a clientId) +// content://org.dslul.openboard.inputmethod.latin.dictionarypack/clientId/list?procotol=2 + val contentUri = 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.Companion.QUERY_PARAMETER_PROTOCOL_VERSION, "2") + .build() + val activity = activity + val cursor = activity?.contentResolver?.query(contentUri, null, null, null, null) + if (null == cursor) { + val result = ArrayList() + result.add(createErrorMessage(activity, R.string.cannot_connect_to_dict_service)) + return result + } + return try { + if (!cursor.moveToFirst()) { + val result = ArrayList() + result.add(createErrorMessage(activity, R.string.no_dictionaries_available)) + return result + } + val systemLocaleString = Locale.getDefault().toString() + val prefMap = TreeMap() + val idIndex = cursor.getColumnIndex(MetadataDbHelper.Companion.WORDLISTID_COLUMN) + val versionIndex = cursor.getColumnIndex(MetadataDbHelper.Companion.VERSION_COLUMN) + val localeIndex = cursor.getColumnIndex(MetadataDbHelper.Companion.LOCALE_COLUMN) + val descriptionIndex = cursor.getColumnIndex(MetadataDbHelper.Companion.DESCRIPTION_COLUMN) + val statusIndex = cursor.getColumnIndex(MetadataDbHelper.Companion.STATUS_COLUMN) + val filesizeIndex = cursor.getColumnIndex(MetadataDbHelper.Companion.FILESIZE_COLUMN) + do { + val wordlistId = cursor.getString(idIndex) + val version = cursor.getInt(versionIndex) + val localeString = cursor.getString(localeIndex) + val locale = Locale(localeString) + val description = cursor.getString(descriptionIndex) + val status = cursor.getInt(statusIndex) + val matchLevel = LocaleUtils.getMatchLevel(systemLocaleString, localeString) + val matchLevelString = LocaleUtils.getMatchLevelSortedString(matchLevel) + val filesize = cursor.getInt(filesizeIndex) + // The key is sorted in lexicographic order, according to the match level, then +// the description. + val key = "$matchLevelString.$description.$wordlistId" + val existingPref = prefMap[key] + if (null == existingPref || existingPref.hasPriorityOver(status)) { + val oldPreference = mCurrentPreferenceMap[key] + val pref: WordListPreference + pref = if (null != oldPreference && oldPreference.mVersion == version && oldPreference.hasStatus(status) + && oldPreference.mLocale == locale) { // If the old preference has all the new attributes, reuse it. Ideally, +// we should reuse the old pref even if its status is different and call +// setStatus here, but setStatus calls Preference#setSummary() which +// needs to be done on the UI thread and we're not on the UI thread +// here. We could do all this work on the UI thread, but in this case +// it's probably lighter to stay on a background thread and throw this +// old preference out. + oldPreference + } else { // Otherwise, discard it and create a new one instead. +// TODO: when the status is different from the old one, we need to +// animate the old one out before animating the new one in. + WordListPreference(activity, mDictionaryListInterfaceState, + mClientId, wordlistId, version, locale, description, status, + filesize) + } + prefMap[key] = pref + } + } while (cursor.moveToNext()) + mCurrentPreferenceMap = prefMap + prefMap.values + } finally { + cursor.close() + } + } + + companion object { + private val TAG = DictionarySettingsFragment::class.java.simpleName + private const val DICT_LIST_ID = "list" + const val DICT_SETTINGS_FRAGMENT_CLIENT_ID_ARGUMENT = "clientId" + private const val MENU_UPDATE_NOW = Menu.FIRST + private fun createErrorMessage(activity: Activity?, messageResource: Int): Preference { + val message = Preference(activity) + message.setTitle(messageResource) + message.isEnabled = false + return message + } + + fun removeAnyDictSettings(prefGroup: PreferenceGroup) { + for (i in prefGroup.preferenceCount - 1 downTo 0) { + prefGroup.removePreference(prefGroup.getPreference(i)) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/LogProblemReporter.java b/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/LogProblemReporter.kt similarity index 65% rename from app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/LogProblemReporter.java rename to app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/LogProblemReporter.kt index edcea6301..8587daadf 100644 --- a/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/LogProblemReporter.java +++ b/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/LogProblemReporter.kt @@ -13,23 +13,16 @@ * License for the specific language governing permissions and limitations under * the License. */ +package org.dslul.openboard.inputmethod.dictionarypack -package org.dslul.openboard.inputmethod.dictionarypack; - -import android.util.Log; +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; +internal class LogProblemReporter(private val TAG: String) : ProblemReporter { + override fun report(e: Exception?) { + Log.e(TAG, "Reporting problem", e) } - @Override - public void report(final Exception e) { - Log.e(TAG, "Reporting problem", e); - } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/MD5Calculator.java b/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/MD5Calculator.java deleted file mode 100644 index dcd6cb44b..000000000 --- a/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/MD5Calculator.java +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Copyright (C) 2012 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not - * use this file except in compliance with the License. You may obtain a copy of - * the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations under - * the License. - */ - -package org.dslul.openboard.inputmethod.dictionarypack; - -import java.io.InputStream; -import java.io.IOException; -import java.security.MessageDigest; - -public final class MD5Calculator { - private MD5Calculator() {} // This helper class is not instantiable - - public static String checksum(final InputStream in) throws IOException { - // This code from the Android documentation for MessageDigest. Nearly verbatim. - MessageDigest digester; - try { - digester = MessageDigest.getInstance("MD5"); - } catch (java.security.NoSuchAlgorithmException e) { - return null; // Platform does not support MD5 : can't check, so return null - } - final byte[] bytes = new byte[8192]; - int byteCount; - while ((byteCount = in.read(bytes)) > 0) { - digester.update(bytes, 0, byteCount); - } - final byte[] digest = digester.digest(); - final StringBuilder s = new StringBuilder(); - for (int i = 0; i < digest.length; ++i) { - s.append(String.format("%1$02x", digest[i])); - } - return s.toString(); - } -} diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/MD5Calculator.kt b/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/MD5Calculator.kt new file mode 100644 index 000000000..0bba897f1 --- /dev/null +++ b/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/MD5Calculator.kt @@ -0,0 +1,44 @@ +/** + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package org.dslul.openboard.inputmethod.dictionarypack + +import java.io.IOException +import java.io.InputStream +import java.security.MessageDigest +import java.security.NoSuchAlgorithmException + +object MD5Calculator { + @Throws(IOException::class) + fun checksum(`in`: InputStream): String? { // This code from the Android documentation for MessageDigest. Nearly verbatim. + val digester: MessageDigest + digester = try { + MessageDigest.getInstance("MD5") + } catch (e: NoSuchAlgorithmException) { + return null // Platform does not support MD5 : can't check, so return null + } + val bytes = ByteArray(8192) + var byteCount: Int + while (`in`.read(bytes).also { byteCount = it } > 0) { + digester.update(bytes, 0, byteCount) + } + val digest = digester.digest() + val s = StringBuilder() + for (i in digest.indices) { + s.append(String.format("%1$02x", digest[i])) + } + return s.toString() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/MetadataDbHelper.java b/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/MetadataDbHelper.java deleted file mode 100644 index 4b5df4727..000000000 --- a/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/MetadataDbHelper.java +++ /dev/null @@ -1,1047 +0,0 @@ -/* - * Copyright (C) 2011 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not - * use this file except in compliance with the License. You may obtain a copy of - * the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations under - * the License. - */ - -package org.dslul.openboard.inputmethod.dictionarypack; - -import android.content.ContentValues; -import android.content.Context; -import android.database.Cursor; -import android.database.sqlite.SQLiteDatabase; -import android.database.sqlite.SQLiteException; -import android.database.sqlite.SQLiteOpenHelper; -import android.text.TextUtils; -import android.util.Log; - -import org.dslul.openboard.inputmethod.latin.R; -import org.dslul.openboard.inputmethod.latin.utils.DebugLogUtils; - -import java.io.File; -import java.util.ArrayList; -import java.util.LinkedList; -import java.util.List; -import java.util.TreeMap; - -import javax.annotation.Nullable; - -/** - * Various helper functions for the state database - */ -public class MetadataDbHelper extends SQLiteOpenHelper { - private static final String TAG = MetadataDbHelper.class.getSimpleName(); - - // This was the initial release version of the database. It should never be - // changed going forward. - private static final int METADATA_DATABASE_INITIAL_VERSION = 3; - // This is the first released version of the database that implements CLIENTID. It is - // used to identify the versions for upgrades. This should never change going forward. - private static final int METADATA_DATABASE_VERSION_WITH_CLIENTID = 6; - // The current database version. - // This MUST be increased every time the dictionary pack metadata URL changes. - private static final int CURRENT_METADATA_DATABASE_VERSION = 16; - - private final static long NOT_A_DOWNLOAD_ID = -1; - - // The number of retries allowed when attempting to download a broken dictionary. - public static final int DICTIONARY_RETRY_THRESHOLD = 2; - - public static final String METADATA_TABLE_NAME = "pendingUpdates"; - static final String CLIENT_TABLE_NAME = "clients"; - public static final String PENDINGID_COLUMN = "pendingid"; // Download Manager ID - public static final String TYPE_COLUMN = "type"; - public static final String STATUS_COLUMN = "status"; - public static final String LOCALE_COLUMN = "locale"; - public static final String WORDLISTID_COLUMN = "id"; - public static final String DESCRIPTION_COLUMN = "description"; - public static final String LOCAL_FILENAME_COLUMN = "filename"; - public static final String REMOTE_FILENAME_COLUMN = "url"; - public static final String DATE_COLUMN = "date"; - public static final String CHECKSUM_COLUMN = "checksum"; - public static final String FILESIZE_COLUMN = "filesize"; - public static final String VERSION_COLUMN = "version"; - public static final String FORMATVERSION_COLUMN = "formatversion"; - public static final String FLAGS_COLUMN = "flags"; - public static final String RAW_CHECKSUM_COLUMN = "rawChecksum"; - public static final String RETRY_COUNT_COLUMN = "remainingRetries"; - public static final int COLUMN_COUNT = 15; - - private static final String CLIENT_CLIENT_ID_COLUMN = "clientid"; - private static final String CLIENT_METADATA_URI_COLUMN = "uri"; - private static final String CLIENT_METADATA_ADDITIONAL_ID_COLUMN = "additionalid"; - private static final String CLIENT_LAST_UPDATE_DATE_COLUMN = "lastupdate"; - private static final String CLIENT_PENDINGID_COLUMN = "pendingid"; // Download Manager ID - - public static final String METADATA_DATABASE_NAME_STEM = "pendingUpdates"; - public static final String METADATA_UPDATE_DESCRIPTION = "metadata"; - - public static final String DICTIONARIES_ASSETS_PATH = "dictionaries"; - - // Statuses, for storing in the STATUS_COLUMN - // IMPORTANT: The following are used as index arrays in ../WordListPreference - // Do not change their values without updating the matched code. - // Unknown status: this should never happen. - public static final int STATUS_UNKNOWN = 0; - // Available: this word list is available, but it is not downloaded (not downloading), because - // it is set not to be used. - public static final int STATUS_AVAILABLE = 1; - // Downloading: this word list is being downloaded. - public static final int STATUS_DOWNLOADING = 2; - // Installed: this word list is installed and usable. - public static final int STATUS_INSTALLED = 3; - // Disabled: this word list is installed, but has been disabled by the user. - public static final int STATUS_DISABLED = 4; - // Deleting: the user marked this word list to be deleted, but it has not been yet because - // Latin IME is not up yet. - public static final int STATUS_DELETING = 5; - // Retry: dictionary got corrupted, so an attempt must be done to download & install it again. - public static final int STATUS_RETRYING = 6; - - // Types, for storing in the TYPE_COLUMN - // This is metadata about what is available. - public static final int TYPE_METADATA = 1; - // This is a bulk file. It should replace older files. - public static final int TYPE_BULK = 2; - // This is an incremental update, expected to be small, and meaningless on its own. - public static final int TYPE_UPDATE = 3; - - private static final String METADATA_TABLE_CREATE = - "CREATE TABLE " + METADATA_TABLE_NAME + " (" - + PENDINGID_COLUMN + " INTEGER, " - + TYPE_COLUMN + " INTEGER, " - + STATUS_COLUMN + " INTEGER, " - + WORDLISTID_COLUMN + " TEXT, " - + LOCALE_COLUMN + " TEXT, " - + DESCRIPTION_COLUMN + " TEXT, " - + LOCAL_FILENAME_COLUMN + " TEXT, " - + REMOTE_FILENAME_COLUMN + " TEXT, " - + DATE_COLUMN + " INTEGER, " - + CHECKSUM_COLUMN + " TEXT, " - + FILESIZE_COLUMN + " INTEGER, " - + VERSION_COLUMN + " INTEGER," - + FORMATVERSION_COLUMN + " INTEGER, " - + FLAGS_COLUMN + " INTEGER, " - + RAW_CHECKSUM_COLUMN + " TEXT," - + RETRY_COUNT_COLUMN + " INTEGER, " - + "PRIMARY KEY (" + WORDLISTID_COLUMN + "," + VERSION_COLUMN + "));"; - private static final String METADATA_CREATE_CLIENT_TABLE = - "CREATE TABLE IF NOT EXISTS " + CLIENT_TABLE_NAME + " (" - + CLIENT_CLIENT_ID_COLUMN + " TEXT, " - + CLIENT_METADATA_URI_COLUMN + " TEXT, " - + CLIENT_METADATA_ADDITIONAL_ID_COLUMN + " TEXT, " - + CLIENT_LAST_UPDATE_DATE_COLUMN + " INTEGER NOT NULL DEFAULT 0, " - + CLIENT_PENDINGID_COLUMN + " INTEGER, " - + FLAGS_COLUMN + " INTEGER, " - + "PRIMARY KEY (" + CLIENT_CLIENT_ID_COLUMN + "));"; - - // List of all metadata table columns. - static final String[] METADATA_TABLE_COLUMNS = { PENDINGID_COLUMN, TYPE_COLUMN, - STATUS_COLUMN, WORDLISTID_COLUMN, LOCALE_COLUMN, DESCRIPTION_COLUMN, - LOCAL_FILENAME_COLUMN, REMOTE_FILENAME_COLUMN, DATE_COLUMN, CHECKSUM_COLUMN, - FILESIZE_COLUMN, VERSION_COLUMN, FORMATVERSION_COLUMN, FLAGS_COLUMN, - RAW_CHECKSUM_COLUMN, RETRY_COUNT_COLUMN }; - // List of all client table columns. - static final String[] CLIENT_TABLE_COLUMNS = { CLIENT_CLIENT_ID_COLUMN, - CLIENT_METADATA_URI_COLUMN, CLIENT_PENDINGID_COLUMN, FLAGS_COLUMN }; - // List of public columns returned to clients. Everything that is not in this list is - // private and implementation-dependent. - static final String[] DICTIONARIES_LIST_PUBLIC_COLUMNS = { STATUS_COLUMN, WORDLISTID_COLUMN, - LOCALE_COLUMN, DESCRIPTION_COLUMN, DATE_COLUMN, FILESIZE_COLUMN, VERSION_COLUMN }; - - // This class exhibits a singleton-like behavior by client ID, so it is getInstance'd - // and has a private c'tor. - private static TreeMap sInstanceMap = null; - public static synchronized MetadataDbHelper getInstance(final Context context, - final String clientIdOrNull) { - // As a backward compatibility feature, null can be passed here to retrieve the "default" - // database. Before multi-client support, the dictionary packed used only one database - // and would not be able to handle several dictionary sets. Passing null here retrieves - // this legacy database. New clients should make sure to always pass a client ID so as - // to avoid conflicts. - final String clientId = null != clientIdOrNull ? clientIdOrNull : ""; - if (null == sInstanceMap) sInstanceMap = new TreeMap<>(); - MetadataDbHelper helper = sInstanceMap.get(clientId); - if (null == helper) { - helper = new MetadataDbHelper(context, clientId); - sInstanceMap.put(clientId, helper); - } - return helper; - } - private MetadataDbHelper(final Context context, final String clientId) { - super(context, - METADATA_DATABASE_NAME_STEM + (TextUtils.isEmpty(clientId) ? "" : "." + clientId), - null, CURRENT_METADATA_DATABASE_VERSION); - mContext = context; - mClientId = clientId; - } - - private final Context mContext; - private final String mClientId; - - /** - * Get the database itself. This always returns the same object for any client ID. If the - * client ID is null, a default database is returned for backward compatibility. Don't - * pass null for new calls. - * - * @param context the context to create the database from. This is ignored after the first call. - * @param clientId the client id to retrieve the database of. null for default (deprecated) - * @return the database. - */ - public static SQLiteDatabase getDb(final Context context, final String clientId) { - return getInstance(context, clientId).getWritableDatabase(); - } - - private void createClientTable(final SQLiteDatabase db) { - // The clients table only exists in the primary db, the one that has an empty client id - if (!TextUtils.isEmpty(mClientId)) return; - db.execSQL(METADATA_CREATE_CLIENT_TABLE); - final String defaultMetadataUri = mContext.getString(R.string.default_metadata_uri); - if (!TextUtils.isEmpty(defaultMetadataUri)) { - final ContentValues defaultMetadataValues = new ContentValues(); - defaultMetadataValues.put(CLIENT_CLIENT_ID_COLUMN, ""); - defaultMetadataValues.put(CLIENT_METADATA_URI_COLUMN, defaultMetadataUri); - db.insert(CLIENT_TABLE_NAME, null, defaultMetadataValues); - } - } - - /** - * Create the table and populate it with the resources found inside the apk. - * - * @see SQLiteOpenHelper#onCreate(SQLiteDatabase) - * - * @param db the database to create and populate. - */ - @Override - public void onCreate(final SQLiteDatabase db) { - db.execSQL(METADATA_TABLE_CREATE); - createClientTable(db); - } - - private static void addRawChecksumColumnUnlessPresent(final SQLiteDatabase db) { - try { - db.execSQL("SELECT " + RAW_CHECKSUM_COLUMN + " FROM " - + METADATA_TABLE_NAME + " LIMIT 0;"); - } catch (SQLiteException e) { - Log.i(TAG, "No " + RAW_CHECKSUM_COLUMN + " column : creating it"); - db.execSQL("ALTER TABLE " + METADATA_TABLE_NAME + " ADD COLUMN " - + RAW_CHECKSUM_COLUMN + " TEXT;"); - } - } - - private static void addRetryCountColumnUnlessPresent(final SQLiteDatabase db) { - try { - db.execSQL("SELECT " + RETRY_COUNT_COLUMN + " FROM " - + METADATA_TABLE_NAME + " LIMIT 0;"); - } catch (SQLiteException e) { - Log.i(TAG, "No " + RETRY_COUNT_COLUMN + " column : creating it"); - db.execSQL("ALTER TABLE " + METADATA_TABLE_NAME + " ADD COLUMN " - + RETRY_COUNT_COLUMN + " INTEGER DEFAULT " + DICTIONARY_RETRY_THRESHOLD + ";"); - } - } - - /** - * Upgrade the database. Upgrade from version 3 is supported. - * Version 3 has a DB named METADATA_DATABASE_NAME_STEM containing a table METADATA_TABLE_NAME. - * Version 6 and above has a DB named METADATA_DATABASE_NAME_STEM containing a - * table CLIENT_TABLE_NAME, and for each client a table called METADATA_TABLE_STEM + "." + the - * name of the client and contains a table METADATA_TABLE_NAME. - * For schemas, see the above create statements. The schemas have never changed so far. - * - * This method is called by the framework. See {@link SQLiteOpenHelper#onUpgrade} - * @param db The database we are upgrading - * @param oldVersion The old database version (the one on the disk) - * @param newVersion The new database version as supplied to the constructor of SQLiteOpenHelper - */ - @Override - public void onUpgrade(final SQLiteDatabase db, final int oldVersion, final int newVersion) { - if (METADATA_DATABASE_INITIAL_VERSION == oldVersion - && METADATA_DATABASE_VERSION_WITH_CLIENTID <= newVersion - && CURRENT_METADATA_DATABASE_VERSION >= newVersion) { - // Upgrade from version METADATA_DATABASE_INITIAL_VERSION to version - // METADATA_DATABASE_VERSION_WITH_CLIENT_ID - // Only the default database should contain the client table, so we test for mClientId. - if (TextUtils.isEmpty(mClientId)) { - // Anyway in version 3 only the default table existed so the emptiness - // test should always be true, but better check to be sure. - createClientTable(db); - } - } else if (METADATA_DATABASE_VERSION_WITH_CLIENTID < newVersion - && CURRENT_METADATA_DATABASE_VERSION >= newVersion) { - // Here we drop the client table, so that all clients send us their information again. - // The client table contains the URL to hit to update the available dictionaries list, - // but the info about the dictionaries themselves is stored in the table called - // METADATA_TABLE_NAME and we want to keep it, so we only drop the client table. - db.execSQL("DROP TABLE IF EXISTS " + CLIENT_TABLE_NAME); - // Only the default database should contain the client table, so we test for mClientId. - if (TextUtils.isEmpty(mClientId)) { - createClientTable(db); - } - } else { - // If we're not in the above case, either we are upgrading from an earlier versionCode - // and we should wipe the database, or we are handling a version we never heard about - // (can only be a bug) so it's safer to wipe the database. - db.execSQL("DROP TABLE IF EXISTS " + METADATA_TABLE_NAME); - db.execSQL("DROP TABLE IF EXISTS " + CLIENT_TABLE_NAME); - onCreate(db); - } - // A rawChecksum column that did not exist in the previous versions was added that - // corresponds to the md5 checksum of the file after decompression/decryption. This is to - // strengthen the system against corrupted dictionary files. - // The most secure way to upgrade a database is to just test for the column presence, and - // add it if it's not there. - addRawChecksumColumnUnlessPresent(db); - - // A retry count column that did not exist in the previous versions was added that - // corresponds to the number of download & installation attempts that have been made - // in order to strengthen the system recovery from corrupted dictionary files. - // The most secure way to upgrade a database is to just test for the column presence, and - // add it if it's not there. - addRetryCountColumnUnlessPresent(db); - } - - /** - * Downgrade the database. This drops and recreates the table in all cases. - */ - @Override - public void onDowngrade(final SQLiteDatabase db, final int oldVersion, final int newVersion) { - // No matter what the numerical values of oldVersion and newVersion are, we know this - // is a downgrade (newVersion < oldVersion). There is no way to know what the future - // databases will look like, but we know it's extremely likely that it's okay to just - // drop the tables and start from scratch. Hence, we ignore the versions and just wipe - // everything we want to use. - if (oldVersion <= newVersion) { - Log.e(TAG, "onDowngrade database but new version is higher? " + oldVersion + " <= " - + newVersion); - } - db.execSQL("DROP TABLE IF EXISTS " + METADATA_TABLE_NAME); - db.execSQL("DROP TABLE IF EXISTS " + CLIENT_TABLE_NAME); - onCreate(db); - } - - /** - * Given a client ID, returns whether this client exists. - * - * @param context a context to open the database - * @param clientId the client ID to check - * @return true if the client is known, false otherwise - */ - public static boolean isClientKnown(final Context context, final String clientId) { - // If the client is known, they'll have a non-null metadata URI. An empty string is - // allowed as a metadata URI, if the client doesn't want any updates to happen. - return null != getMetadataUriAsString(context, clientId); - } - - private static final MetadataUriGetter sMetadataUriGetter = new MetadataUriGetter(); - - /** - * Returns the metadata URI as a string. - * - * If the client is not known, this will return null. If it is known, it will return - * the URI as a string. Note that the empty string is a valid value. - * - * @param context a context instance to open the database on - * @param clientId the ID of the client we want the metadata URI of - * @return the string representation of the URI - */ - public static String getMetadataUriAsString(final Context context, final String clientId) { - SQLiteDatabase defaultDb = MetadataDbHelper.getDb(context, null); - final Cursor cursor = defaultDb.query(MetadataDbHelper.CLIENT_TABLE_NAME, - new String[] { MetadataDbHelper.CLIENT_METADATA_URI_COLUMN }, - MetadataDbHelper.CLIENT_CLIENT_ID_COLUMN + " = ?", new String[] { clientId }, - null, null, null, null); - try { - if (!cursor.moveToFirst()) return null; - return sMetadataUriGetter.getUri(context, cursor.getString(0)); - } finally { - cursor.close(); - } - } - - /** - * Update the last metadata update time for all clients using a particular URI. - * - * This method searches for all clients using a particular URI and updates the last - * update time for this client. - * The current time is used as the latest update time. This saved date will be what - * is returned henceforth by {@link #getLastUpdateDateForClient(Context, String)}, - * until this method is called again. - * - * @param context a context instance to open the database on - * @param uri the metadata URI we just downloaded - */ - public static void saveLastUpdateTimeOfUri(final Context context, final String uri) { - PrivateLog.log("Save last update time of URI : " + uri + " " + System.currentTimeMillis()); - final ContentValues values = new ContentValues(); - values.put(CLIENT_LAST_UPDATE_DATE_COLUMN, System.currentTimeMillis()); - final SQLiteDatabase defaultDb = getDb(context, null); - final Cursor cursor = MetadataDbHelper.queryClientIds(context); - if (null == cursor) return; - try { - if (!cursor.moveToFirst()) return; - do { - final String clientId = cursor.getString(0); - final String metadataUri = - MetadataDbHelper.getMetadataUriAsString(context, clientId); - if (metadataUri.equals(uri)) { - defaultDb.update(CLIENT_TABLE_NAME, values, - CLIENT_CLIENT_ID_COLUMN + " = ?", new String[] { clientId }); - } - } while (cursor.moveToNext()); - } finally { - cursor.close(); - } - } - - /** - * Retrieves the last date at which we updated the metadata for this client. - * - * The returned date is in milliseconds from the EPOCH; this is the same unit as - * returned by {@link System#currentTimeMillis()}. - * - * @param context a context instance to open the database on - * @param clientId the client ID to get the latest update date of - * @return the last date at which this client was updated, as a long. - */ - public static long getLastUpdateDateForClient(final Context context, final String clientId) { - SQLiteDatabase defaultDb = getDb(context, null); - final Cursor cursor = defaultDb.query(CLIENT_TABLE_NAME, - new String[] { CLIENT_LAST_UPDATE_DATE_COLUMN }, - CLIENT_CLIENT_ID_COLUMN + " = ?", - new String[] { null == clientId ? "" : clientId }, - null, null, null, null); - try { - if (!cursor.moveToFirst()) return 0; - return cursor.getLong(0); // Only one column, return it - } finally { - cursor.close(); - } - } - - public static long getOldestUpdateTime(final Context context) { - SQLiteDatabase defaultDb = getDb(context, null); - final Cursor cursor = defaultDb.query(CLIENT_TABLE_NAME, - new String[] { CLIENT_LAST_UPDATE_DATE_COLUMN }, - null, null, null, null, null); - try { - if (!cursor.moveToFirst()) return 0; - final int columnIndex = 0; // Only one column queried - // Initialize the earliestTime to the largest possible value. - long earliestTime = Long.MAX_VALUE; // Almost 300 million years in the future - do { - final long thisTime = cursor.getLong(columnIndex); - earliestTime = Math.min(thisTime, earliestTime); - } while (cursor.moveToNext()); - return earliestTime; - } finally { - cursor.close(); - } - } - - /** - * Helper method to make content values to write into the database. - * @return content values with all the arguments put with the right column names. - */ - public static ContentValues makeContentValues(final int pendingId, final int type, - final int status, final String wordlistId, final String locale, - final String description, final String filename, final String url, final long date, - final String rawChecksum, final String checksum, final int retryCount, - final long filesize, final int version, final int formatVersion) { - final ContentValues result = new ContentValues(COLUMN_COUNT); - result.put(PENDINGID_COLUMN, pendingId); - result.put(TYPE_COLUMN, type); - result.put(WORDLISTID_COLUMN, wordlistId); - result.put(STATUS_COLUMN, status); - result.put(LOCALE_COLUMN, locale); - result.put(DESCRIPTION_COLUMN, description); - result.put(LOCAL_FILENAME_COLUMN, filename); - result.put(REMOTE_FILENAME_COLUMN, url); - result.put(DATE_COLUMN, date); - result.put(RAW_CHECKSUM_COLUMN, rawChecksum); - result.put(RETRY_COUNT_COLUMN, retryCount); - result.put(CHECKSUM_COLUMN, checksum); - result.put(FILESIZE_COLUMN, filesize); - result.put(VERSION_COLUMN, version); - result.put(FORMATVERSION_COLUMN, formatVersion); - result.put(FLAGS_COLUMN, 0); - return result; - } - - /** - * Helper method to fill in an incomplete ContentValues with default values. - * A wordlist ID and a locale are required, otherwise BadFormatException is thrown. - * @return the same object that was passed in, completed with default values. - */ - public static ContentValues completeWithDefaultValues(final ContentValues result) - throws BadFormatException { - if (null == result.get(WORDLISTID_COLUMN) || null == result.get(LOCALE_COLUMN)) { - throw new BadFormatException(); - } - // 0 for the pending id, because there is none - if (null == result.get(PENDINGID_COLUMN)) result.put(PENDINGID_COLUMN, 0); - // This is a binary blob of a dictionary - if (null == result.get(TYPE_COLUMN)) result.put(TYPE_COLUMN, TYPE_BULK); - // This word list is unknown, but it's present, else we wouldn't be here, so INSTALLED - if (null == result.get(STATUS_COLUMN)) result.put(STATUS_COLUMN, STATUS_INSTALLED); - // No description unless specified, because we can't guess it - if (null == result.get(DESCRIPTION_COLUMN)) result.put(DESCRIPTION_COLUMN, ""); - // File name - this is an asset, so it works as an already deleted file. - // hence, we need to supply a non-existent file name. Anything will - // do as long as it returns false when tested with File#exist(), and - // the empty string does not, so it's set to "_". - if (null == result.get(LOCAL_FILENAME_COLUMN)) result.put(LOCAL_FILENAME_COLUMN, "_"); - // No remote file name : this can't be downloaded. Unless specified. - if (null == result.get(REMOTE_FILENAME_COLUMN)) result.put(REMOTE_FILENAME_COLUMN, ""); - // 0 for the update date : 1970/1/1. Unless specified. - if (null == result.get(DATE_COLUMN)) result.put(DATE_COLUMN, 0); - // Raw checksum unknown unless specified - if (null == result.get(RAW_CHECKSUM_COLUMN)) result.put(RAW_CHECKSUM_COLUMN, ""); - // Retry column 0 unless specified - if (null == result.get(RETRY_COUNT_COLUMN)) result.put(RETRY_COUNT_COLUMN, - DICTIONARY_RETRY_THRESHOLD); - // Checksum unknown unless specified - if (null == result.get(CHECKSUM_COLUMN)) result.put(CHECKSUM_COLUMN, ""); - // No filesize unless specified - if (null == result.get(FILESIZE_COLUMN)) result.put(FILESIZE_COLUMN, 0); - // Smallest possible version unless specified - if (null == result.get(VERSION_COLUMN)) result.put(VERSION_COLUMN, 1); - // No flags unless specified - if (null == result.get(FLAGS_COLUMN)) result.put(FLAGS_COLUMN, 0); - return result; - } - - /** - * Reads a column in a Cursor as a String and stores it in a ContentValues object. - * @param result the ContentValues object to store the result in. - * @param cursor the Cursor to read the column from. - * @param columnId the column ID to read. - */ - private static void putStringResult(ContentValues result, Cursor cursor, String columnId) { - result.put(columnId, cursor.getString(cursor.getColumnIndex(columnId))); - } - - /** - * Reads a column in a Cursor as an int and stores it in a ContentValues object. - * @param result the ContentValues object to store the result in. - * @param cursor the Cursor to read the column from. - * @param columnId the column ID to read. - */ - private static void putIntResult(ContentValues result, Cursor cursor, String columnId) { - result.put(columnId, cursor.getInt(cursor.getColumnIndex(columnId))); - } - - private static ContentValues getFirstLineAsContentValues(final Cursor cursor) { - final ContentValues result; - if (cursor.moveToFirst()) { - result = new ContentValues(COLUMN_COUNT); - putIntResult(result, cursor, PENDINGID_COLUMN); - putIntResult(result, cursor, TYPE_COLUMN); - putIntResult(result, cursor, STATUS_COLUMN); - putStringResult(result, cursor, WORDLISTID_COLUMN); - putStringResult(result, cursor, LOCALE_COLUMN); - putStringResult(result, cursor, DESCRIPTION_COLUMN); - putStringResult(result, cursor, LOCAL_FILENAME_COLUMN); - putStringResult(result, cursor, REMOTE_FILENAME_COLUMN); - putIntResult(result, cursor, DATE_COLUMN); - putStringResult(result, cursor, RAW_CHECKSUM_COLUMN); - putStringResult(result, cursor, CHECKSUM_COLUMN); - putIntResult(result, cursor, RETRY_COUNT_COLUMN); - putIntResult(result, cursor, FILESIZE_COLUMN); - putIntResult(result, cursor, VERSION_COLUMN); - putIntResult(result, cursor, FORMATVERSION_COLUMN); - putIntResult(result, cursor, FLAGS_COLUMN); - if (cursor.moveToNext()) { - // TODO: print the second level of the stack to the log so that we know - // in which code path the error happened - Log.e(TAG, "Several SQL results when we expected only one!"); - } - } else { - result = null; - } - return result; - } - - /** - * Gets the info about as specific download, indexed by its DownloadManager ID. - * @param db the database to get the information from. - * @param id the DownloadManager id. - * @return metadata about this download. This returns all columns in the database. - */ - public static ContentValues getContentValuesByPendingId(final SQLiteDatabase db, - final long id) { - final Cursor cursor = db.query(METADATA_TABLE_NAME, - METADATA_TABLE_COLUMNS, - PENDINGID_COLUMN + "= ?", - new String[] { Long.toString(id) }, - null, null, null); - if (null == cursor) { - return null; - } - try { - // There should never be more than one result. If because of some bug there are, - // returning only one result is the right thing to do, because we couldn't handle - // several anyway and we should still handle one. - return getFirstLineAsContentValues(cursor); - } finally { - cursor.close(); - } - } - - /** - * Gets the info about an installed OR deleting word list with a specified id. - * - * Basically, this is the word list that we want to return to 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); - if (null == cursor) { - return null; - } - try { - // There should only be one result, but if there are several, we can't tell which - // is the best, so we just return the first one. - return getFirstLineAsContentValues(cursor); - } finally { - cursor.close(); - } - } - - /** - * Gets the info about a specific word list. - * - * @param db the database to get the information from. - * @param id the word list ID. - * @param version the word list version. - * @return the metadata about this word list. - */ - @Nullable - public static ContentValues getContentValuesByWordListId(final SQLiteDatabase db, - final String id, final int version) { - final Cursor cursor = db.query(METADATA_TABLE_NAME, - METADATA_TABLE_COLUMNS, - WORDLISTID_COLUMN + "= ? AND " + VERSION_COLUMN + "= ? AND " - + FORMATVERSION_COLUMN + "<= ?", - new String[] - { id, - Integer.toString(version), - Integer.toString(version) - }, - null /* groupBy */, - null /* having */, - FORMATVERSION_COLUMN + " DESC"/* orderBy */); - if (null == cursor) { - return null; - } - try { - // This is a lookup by primary key, so there can't be more than one result. - return getFirstLineAsContentValues(cursor); - } finally { - cursor.close(); - } - } - - /** - * Gets the info about the latest word list with an id. - * - * @param db the database to get the information from. - * @param id the word list ID. - * @return the metadata about the word list with this id and the latest version number. - */ - public static ContentValues getContentValuesOfLatestAvailableWordlistById( - final SQLiteDatabase db, final String id) { - final Cursor cursor = db.query(METADATA_TABLE_NAME, - METADATA_TABLE_COLUMNS, - WORDLISTID_COLUMN + "= ?", - new String[] { id }, null, null, VERSION_COLUMN + " DESC", "1"); - if (null == cursor) { - return null; - } - try { - // Return the first result from the list of results. - return getFirstLineAsContentValues(cursor); - } finally { - cursor.close(); - } - } - - /** - * Gets the current metadata about INSTALLED, AVAILABLE or DELETING dictionaries. - * - * This odd method is tailored to the needs of - * DictionaryProvider#getDictionaryWordListsForContentUri, which needs the word list if - * it is: - * - INSTALLED: this should be returned to LatinIME if the file is still inside the dictionary - * pack, so that it can be copied. If the file is not there, it's been copied already and should - * not be returned, so getDictionaryWordListsForContentUri takes care of this. - * - DELETING: this should be returned to LatinIME so that it can actually delete the file. - * - AVAILABLE: this should not be returned, but should be checked for auto-installation. - * - * @param context the context for getting the database. - * @param clientId the client id for retrieving the database. null for default (deprecated) - * @return a cursor with metadata about usable dictionaries. - */ - public static Cursor queryInstalledOrDeletingOrAvailableDictionaryMetadata( - final Context context, final String clientId) { - // If clientId is null, we get the defaut DB (see #getInstance() for more about this) - final Cursor results = getDb(context, clientId).query(METADATA_TABLE_NAME, - METADATA_TABLE_COLUMNS, - STATUS_COLUMN + " = ? OR " + STATUS_COLUMN + " = ? OR " + STATUS_COLUMN + " = ?", - new String[] { Integer.toString(STATUS_INSTALLED), - Integer.toString(STATUS_DELETING), - Integer.toString(STATUS_AVAILABLE) }, - null, null, LOCALE_COLUMN); - return results; - } - - /** - * Gets the current metadata about all dictionaries. - * - * This will retrieve the metadata about all dictionaries, including - * older files, or files not yet downloaded. - * - * @param context the context for getting the database. - * @param clientId the client id for retrieving the database. null for default (deprecated) - * @return a cursor with metadata about usable dictionaries. - */ - public static Cursor queryCurrentMetadata(final Context context, final String clientId) { - // If clientId is null, we get the defaut DB (see #getInstance() for more about this) - final Cursor results = getDb(context, clientId).query(METADATA_TABLE_NAME, - METADATA_TABLE_COLUMNS, null, null, null, null, LOCALE_COLUMN); - return results; - } - - /** - * Gets the list of all dictionaries known to the dictionary provider, with only public columns. - * - * This will retrieve information about all known dictionaries, and their status. As such, - * it will also return information about dictionaries on the server that have not been - * downloaded yet, but may be requested. - * This only returns public columns. It does not populate internal columns in the returned - * cursor. - * The value returned by this method is intended to be good to be returned directly for a - * request of the list of dictionaries by a client. - * - * @param context the context to read the database from. - * @param clientId the client id for retrieving the database. null for default (deprecated) - * @return a cursor that lists all available dictionaries and their metadata. - */ - public static Cursor queryDictionaries(final Context context, final String clientId) { - // If clientId is null, we get the defaut DB (see #getInstance() for more about this) - final Cursor results = getDb(context, clientId).query(METADATA_TABLE_NAME, - DICTIONARIES_LIST_PUBLIC_COLUMNS, - // Filter out empty locales so as not to return auxiliary data, like a - // data line for downloading metadata: - MetadataDbHelper.LOCALE_COLUMN + " != ?", new String[] {""}, - // TODO: Reinstate the following code for bulk, then implement partial updates - /* MetadataDbHelper.TYPE_COLUMN + " = ?", - new String[] { Integer.toString(MetadataDbHelper.TYPE_BULK) }, */ - null, null, LOCALE_COLUMN); - return results; - } - - /** - * Deletes all data associated with a client. - * - * @param context the context for opening the database - * @param clientId the ID of the client to delete. - * @return true if the client was successfully deleted, false otherwise. - */ - public static boolean deleteClient(final Context context, final String clientId) { - // Remove all metadata associated with this client - final SQLiteDatabase db = getDb(context, clientId); - db.execSQL("DROP TABLE IF EXISTS " + METADATA_TABLE_NAME); - db.execSQL(METADATA_TABLE_CREATE); - // Remove this client's entry in the clients table - final SQLiteDatabase defaultDb = getDb(context, ""); - if (0 == defaultDb.delete(CLIENT_TABLE_NAME, - CLIENT_CLIENT_ID_COLUMN + " = ?", new String[] { clientId })) { - return false; - } - return true; - } - - /** - * Updates information relative to a specific client. - * - * Updatable information includes the metadata URI and the additional ID column. It may be - * expanded in the future. - * The passed values must include a client ID in the key CLIENT_CLIENT_ID_COLUMN, and it must - * be equal to the string passed as an argument for clientId. It may not be empty. - * The passed values must also include a non-null metadata URI in the - * CLIENT_METADATA_URI_COLUMN column, as well as a non-null additional ID in the - * CLIENT_METADATA_ADDITIONAL_ID_COLUMN. Both these strings may be empty. - * If any of the above is not complied with, this function returns without updating data. - * - * @param context the context, to open the database - * @param clientId the ID of the client to update - * @param values the values to update. Must conform to the protocol (see above) - */ - public static void updateClientInfo(final Context context, final String clientId, - final ContentValues values) { - // Sanity check the content values - final String valuesClientId = values.getAsString(CLIENT_CLIENT_ID_COLUMN); - final String valuesMetadataUri = values.getAsString(CLIENT_METADATA_URI_COLUMN); - final String valuesMetadataAdditionalId = - values.getAsString(CLIENT_METADATA_ADDITIONAL_ID_COLUMN); - // Empty string is a valid client ID, but external apps may not configure it, so disallow - // both null and empty string. - // Empty string is a valid metadata URI if the client does not want updates, so allow - // empty string but disallow null. - // Empty string is a valid additional ID so allow empty string but disallow null. - if (TextUtils.isEmpty(valuesClientId) || null == valuesMetadataUri - || null == valuesMetadataAdditionalId) { - // We need all these columns to be filled in - DebugLogUtils.l("Missing parameter for updateClientInfo"); - return; - } - if (!clientId.equals(valuesClientId)) { - // Mismatch! The client violates the protocol. - DebugLogUtils.l("Received an updateClientInfo request for ", clientId, - " but the values " + "contain a different ID : ", valuesClientId); - return; - } - // Default value for a pending ID is NOT_AN_ID - 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); - } - - /** - * Marks a downloading entry as having successfully downloaded and being installed. - * - * The metadata database contains information about ongoing processes, typically ongoing - * downloads. This marks such an entry as having finished and having installed successfully, - * so it becomes INSTALLED. - * - * @param db the metadata database. - * @param r content values about the entry to mark as processed. - */ - public static void markEntryAsFinishedDownloadingAndInstalled(final SQLiteDatabase db, - final ContentValues r) { - switch (r.getAsInteger(TYPE_COLUMN)) { - case TYPE_BULK: - DebugLogUtils.l("Ended processing a wordlist"); - // Updating a bulk word list is a three-step operation: - // - Add the new entry to the table - // - Remove the old entry from the table - // - Erase the old file - // We start by gathering the names of the files we should delete. - final List filenames = new LinkedList<>(); - final Cursor c = db.query(METADATA_TABLE_NAME, - new String[] { LOCAL_FILENAME_COLUMN }, - LOCALE_COLUMN + " = ? AND " + - WORDLISTID_COLUMN + " = ? AND " + STATUS_COLUMN + " = ?", - new String[] { r.getAsString(LOCALE_COLUMN), - r.getAsString(WORDLISTID_COLUMN), - Integer.toString(STATUS_INSTALLED) }, - null, null, null); - try { - if (c.moveToFirst()) { - // There should never be more than one file, but if there are, it's a bug - // and we should remove them all. I think it might happen if the power of - // the phone is suddenly cut during an update. - final int filenameIndex = c.getColumnIndex(LOCAL_FILENAME_COLUMN); - do { - DebugLogUtils.l("Setting for removal", c.getString(filenameIndex)); - filenames.add(c.getString(filenameIndex)); - } while (c.moveToNext()); - } - } finally { - c.close(); - } - r.put(STATUS_COLUMN, STATUS_INSTALLED); - db.beginTransactionNonExclusive(); - // Delete all old entries. There should never be any stalled entries, but if - // there are, this deletes them. - db.delete(METADATA_TABLE_NAME, - WORDLISTID_COLUMN + " = ?", - new String[] { r.getAsString(WORDLISTID_COLUMN) }); - db.insert(METADATA_TABLE_NAME, null, r); - db.setTransactionSuccessful(); - db.endTransaction(); - for (String filename : filenames) { - try { - final File f = new File(filename); - f.delete(); - } catch (SecurityException e) { - // No permissions to delete. Um. Can't do anything. - } // I don't think anything else can be thrown - } - break; - default: - // Unknown type: do nothing. - break; - } - } - - /** - * Removes a downloading entry from the database. - * - * This is invoked when a download fails. Either we tried to download, but - * we received a permanent failure and we should remove it, or we got manually - * cancelled and we should leave it at that. - * - * @param db the metadata database. - * @param id the DownloadManager id of the file. - */ - public static void deleteDownloadingEntry(final SQLiteDatabase db, final long id) { - db.delete(METADATA_TABLE_NAME, PENDINGID_COLUMN + " = ? AND " + STATUS_COLUMN + " = ?", - new String[] { Long.toString(id), Integer.toString(STATUS_DOWNLOADING) }); - } - - /** - * Forcefully removes an entry from the database. - * - * This is invoked when a file is broken. The file has been downloaded, but Android - * Keyboard is telling us it could not open it. - * - * @param db the metadata database. - * @param id the id of the word list. - * @param version the version of the word list. - */ - public static void deleteEntry(final SQLiteDatabase db, final String id, final int version) { - db.delete(METADATA_TABLE_NAME, WORDLISTID_COLUMN + " = ? AND " + VERSION_COLUMN + " = ?", - new String[] { id, Integer.toString(version) }); - } - - /** - * Internal method that sets the current status of an entry of the database. - * - * @param db the metadata database. - * @param id the id of the word list. - * @param version the version of the word list. - * @param status the status to set the word list to. - * @param downloadId an optional download id to write, or NOT_A_DOWNLOAD_ID - */ - private static void markEntryAs(final SQLiteDatabase db, final String id, - final int version, final int status, final long downloadId) { - final ContentValues values = MetadataDbHelper.getContentValuesByWordListId(db, id, version); - values.put(STATUS_COLUMN, status); - if (NOT_A_DOWNLOAD_ID != downloadId) { - values.put(MetadataDbHelper.PENDINGID_COLUMN, downloadId); - } - db.update(METADATA_TABLE_NAME, values, - WORDLISTID_COLUMN + " = ? AND " + VERSION_COLUMN + " = ?", - new String[] { id, Integer.toString(version) }); - } - - /** - * Writes the status column for the wordlist with this id as enabled. Typically this - * means the word list is currently disabled and we want to set its status to INSTALLED. - * - * @param db the metadata database. - * @param id the id of the word list. - * @param version the version of the word list. - */ - public static void markEntryAsEnabled(final SQLiteDatabase db, final String id, - final int version) { - markEntryAs(db, id, version, STATUS_INSTALLED, NOT_A_DOWNLOAD_ID); - } - - /** - * Writes the status column for the wordlist with this id as disabled. Typically this - * means the word list is currently installed and we want to set its status to DISABLED. - * - * @param db the metadata database. - * @param id the id of the word list. - * @param version the version of the word list. - */ - public static void markEntryAsDisabled(final SQLiteDatabase db, final String id, - final int version) { - markEntryAs(db, id, version, STATUS_DISABLED, NOT_A_DOWNLOAD_ID); - } - - /** - * Writes the status column for the wordlist with this id as available. This happens for - * example when a word list has been deleted but can be downloaded again. - * - * @param db the metadata database. - * @param id the id of the word list. - * @param version the version of the word list. - */ - public static void markEntryAsAvailable(final SQLiteDatabase db, final String id, - final int version) { - markEntryAs(db, id, version, STATUS_AVAILABLE, NOT_A_DOWNLOAD_ID); - } - - /** - * Writes the designated word list as downloadable, alongside with its download id. - * - * @param db the metadata database. - * @param id the id of the word list. - * @param version the version of the word list. - * @param downloadId the download id. - */ - public static void markEntryAsDownloading(final SQLiteDatabase db, final String id, - final int version, final long downloadId) { - markEntryAs(db, id, version, STATUS_DOWNLOADING, downloadId); - } - - /** - * Writes the designated word list as deleting. - * - * @param db the metadata database. - * @param id the id of the word list. - * @param version the version of the word list. - */ - public static void markEntryAsDeleting(final SQLiteDatabase db, final String id, - final int version) { - markEntryAs(db, id, version, STATUS_DELETING, NOT_A_DOWNLOAD_ID); - } - - /** - * Checks retry counts and marks the word list as retrying if retry is possible. - * - * @param db the metadata database. - * @param id the id of the word list. - * @param version the version of the word list. - * @return {@code true} if the retry is possible. - */ - public static boolean maybeMarkEntryAsRetrying(final SQLiteDatabase db, final String id, - final int version) { - final ContentValues values = MetadataDbHelper.getContentValuesByWordListId(db, id, version); - int retryCount = values.getAsInteger(MetadataDbHelper.RETRY_COUNT_COLUMN); - if (retryCount > 1) { - values.put(STATUS_COLUMN, STATUS_RETRYING); - values.put(RETRY_COUNT_COLUMN, retryCount - 1); - db.update(METADATA_TABLE_NAME, values, - WORDLISTID_COLUMN + " = ? AND " + VERSION_COLUMN + " = ?", - new String[] { id, Integer.toString(version) }); - return true; - } - return false; - } -} diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/MetadataDbHelper.kt b/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/MetadataDbHelper.kt new file mode 100644 index 000000000..640ee29e1 --- /dev/null +++ b/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/MetadataDbHelper.kt @@ -0,0 +1,952 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package org.dslul.openboard.inputmethod.dictionarypack + +import android.content.ContentValues +import android.content.Context +import android.database.Cursor +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteException +import android.database.sqlite.SQLiteOpenHelper +import android.text.TextUtils +import android.util.Log +import org.dslul.openboard.inputmethod.latin.R +import org.dslul.openboard.inputmethod.latin.utils.DebugLogUtils +import java.io.File +import java.util.* + +/** + * Various helper functions for the state database + */ +class MetadataDbHelper private constructor(private val mContext: Context?, private val mClientId: String) : SQLiteOpenHelper(mContext, + METADATA_DATABASE_NAME_STEM + if (TextUtils.isEmpty(mClientId)) "" else ".$mClientId", + null, CURRENT_METADATA_DATABASE_VERSION) { + private fun createClientTable(db: SQLiteDatabase) { // 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) + val defaultMetadataUri = mContext!!.getString(R.string.default_metadata_uri) + if (!TextUtils.isEmpty(defaultMetadataUri)) { + val defaultMetadataValues = 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 + * @param db the database to create and populate. + */ + override fun onCreate(db: SQLiteDatabase) { + db.execSQL(METADATA_TABLE_CREATE) + createClientTable(db) + } + + /** + * Upgrade the database. Upgrade from version 3 is supported. + * Version 3 has a DB named METADATA_DATABASE_NAME_STEM containing a table METADATA_TABLE_NAME. + * Version 6 and above has a DB named METADATA_DATABASE_NAME_STEM containing a + * table CLIENT_TABLE_NAME, and for each client a table called METADATA_TABLE_STEM + "." + the + * name of the client and contains a table METADATA_TABLE_NAME. + * For schemas, see the above create statements. The schemas have never changed so far. + * + * This method is called by the framework. See [SQLiteOpenHelper.onUpgrade] + * @param db The database we are upgrading + * @param oldVersion The old database version (the one on the disk) + * @param newVersion The new database version as supplied to the constructor of SQLiteOpenHelper + */ + override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + if (METADATA_DATABASE_INITIAL_VERSION == oldVersion && METADATA_DATABASE_VERSION_WITH_CLIENTID <= newVersion && CURRENT_METADATA_DATABASE_VERSION >= newVersion) { // Upgrade from version METADATA_DATABASE_INITIAL_VERSION to version +// METADATA_DATABASE_VERSION_WITH_CLIENT_ID +// Only the default database should contain the client table, so we test for mClientId. + if (TextUtils.isEmpty(mClientId)) { // Anyway in version 3 only the default table existed so the emptiness +// test should always be true, but better check to be sure. + createClientTable(db) + } + } else if (METADATA_DATABASE_VERSION_WITH_CLIENTID < newVersion + && CURRENT_METADATA_DATABASE_VERSION >= newVersion) { // Here we drop the client table, so that all clients send us their information again. +// The client table contains the URL to hit to update the available dictionaries list, +// but the info about the dictionaries themselves is stored in the table called +// METADATA_TABLE_NAME and we want to keep it, so we only drop the client table. + db.execSQL("DROP TABLE IF EXISTS $CLIENT_TABLE_NAME") + // Only the default database should contain the client table, so we test for mClientId. + if (TextUtils.isEmpty(mClientId)) { + createClientTable(db) + } + } else { // If we're not in the above case, either we are upgrading from an earlier versionCode +// and we should wipe the database, or we are handling a version we never heard about +// (can only be a bug) so it's safer to wipe the database. + db.execSQL("DROP TABLE IF EXISTS $METADATA_TABLE_NAME") + db.execSQL("DROP TABLE IF EXISTS $CLIENT_TABLE_NAME") + onCreate(db) + } + // A rawChecksum column that did not exist in the previous versions was added that +// corresponds to the md5 checksum of the file after decompression/decryption. This is to +// strengthen the system against corrupted dictionary files. +// The most secure way to upgrade a database is to just test for the column presence, and +// add it if it's not there. + addRawChecksumColumnUnlessPresent(db) + // A retry count column that did not exist in the previous versions was added that +// corresponds to the number of download & installation attempts that have been made +// in order to strengthen the system recovery from corrupted dictionary files. +// The most secure way to upgrade a database is to just test for the column presence, and +// add it if it's not there. + addRetryCountColumnUnlessPresent(db) + } + + /** + * Downgrade the database. This drops and recreates the table in all cases. + */ + override fun onDowngrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { // 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) + } + + companion object { + private val TAG = MetadataDbHelper::class.java.simpleName + // This was the initial release version of the database. It should never be +// changed going forward. + private const val 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 const val METADATA_DATABASE_VERSION_WITH_CLIENTID = 6 + // The current database version. +// This MUST be increased every time the dictionary pack metadata URL changes. + private const val CURRENT_METADATA_DATABASE_VERSION = 16 + private const val NOT_A_DOWNLOAD_ID: Long = -1 + // The number of retries allowed when attempting to download a broken dictionary. + const val DICTIONARY_RETRY_THRESHOLD = 2 + const val METADATA_TABLE_NAME = "pendingUpdates" + const val CLIENT_TABLE_NAME = "clients" + const val PENDINGID_COLUMN = "pendingid" // Download Manager ID + const val TYPE_COLUMN = "type" + const val STATUS_COLUMN = "status" + const val LOCALE_COLUMN = "locale" + const val WORDLISTID_COLUMN = "id" + const val DESCRIPTION_COLUMN = "description" + const val LOCAL_FILENAME_COLUMN = "filename" + const val REMOTE_FILENAME_COLUMN = "url" + const val DATE_COLUMN = "date" + const val CHECKSUM_COLUMN = "checksum" + const val FILESIZE_COLUMN = "filesize" + const val VERSION_COLUMN = "version" + const val FORMATVERSION_COLUMN = "formatversion" + const val FLAGS_COLUMN = "flags" + const val RAW_CHECKSUM_COLUMN = "rawChecksum" + const val RETRY_COUNT_COLUMN = "remainingRetries" + const val COLUMN_COUNT = 15 + private const val CLIENT_CLIENT_ID_COLUMN = "clientid" + private const val CLIENT_METADATA_URI_COLUMN = "uri" + private const val CLIENT_METADATA_ADDITIONAL_ID_COLUMN = "additionalid" + private const val CLIENT_LAST_UPDATE_DATE_COLUMN = "lastupdate" + private const val CLIENT_PENDINGID_COLUMN = "pendingid" // Download Manager ID + const val METADATA_DATABASE_NAME_STEM = "pendingUpdates" + const val METADATA_UPDATE_DESCRIPTION = "metadata" + const val 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. + const val STATUS_UNKNOWN = 0 + // Available: this word list is available, but it is not downloaded (not downloading), because +// it is set not to be used. + const val STATUS_AVAILABLE = 1 + // Downloading: this word list is being downloaded. + const val STATUS_DOWNLOADING = 2 + // Installed: this word list is installed and usable. + const val STATUS_INSTALLED = 3 + // Disabled: this word list is installed, but has been disabled by the user. + const val 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. + const val STATUS_DELETING = 5 + // Retry: dictionary got corrupted, so an attempt must be done to download & install it again. + const val STATUS_RETRYING = 6 + // Types, for storing in the TYPE_COLUMN +// This is metadata about what is available. + const val TYPE_METADATA = 1 + // This is a bulk file. It should replace older files. + const val TYPE_BULK = 2 + // This is an incremental update, expected to be small, and meaningless on its own. + const val TYPE_UPDATE = 3 + private const val METADATA_TABLE_CREATE = ("CREATE TABLE " + METADATA_TABLE_NAME + " (" + + PENDINGID_COLUMN + " INTEGER, " + + TYPE_COLUMN + " INTEGER, " + + STATUS_COLUMN + " INTEGER, " + + WORDLISTID_COLUMN + " TEXT, " + + LOCALE_COLUMN + " TEXT, " + + DESCRIPTION_COLUMN + " TEXT, " + + LOCAL_FILENAME_COLUMN + " TEXT, " + + REMOTE_FILENAME_COLUMN + " TEXT, " + + DATE_COLUMN + " INTEGER, " + + CHECKSUM_COLUMN + " TEXT, " + + FILESIZE_COLUMN + " INTEGER, " + + VERSION_COLUMN + " INTEGER," + + FORMATVERSION_COLUMN + " INTEGER, " + + FLAGS_COLUMN + " INTEGER, " + + RAW_CHECKSUM_COLUMN + " TEXT," + + RETRY_COUNT_COLUMN + " INTEGER, " + + "PRIMARY KEY (" + WORDLISTID_COLUMN + "," + VERSION_COLUMN + "));") + private const val METADATA_CREATE_CLIENT_TABLE = ("CREATE TABLE IF NOT EXISTS " + CLIENT_TABLE_NAME + " (" + + CLIENT_CLIENT_ID_COLUMN + " TEXT, " + + CLIENT_METADATA_URI_COLUMN + " TEXT, " + + CLIENT_METADATA_ADDITIONAL_ID_COLUMN + " TEXT, " + + CLIENT_LAST_UPDATE_DATE_COLUMN + " INTEGER NOT NULL DEFAULT 0, " + + CLIENT_PENDINGID_COLUMN + " INTEGER, " + + FLAGS_COLUMN + " INTEGER, " + + "PRIMARY KEY (" + CLIENT_CLIENT_ID_COLUMN + "));") + // List of all metadata table columns. + val METADATA_TABLE_COLUMNS = arrayOf(PENDINGID_COLUMN, TYPE_COLUMN, + STATUS_COLUMN, WORDLISTID_COLUMN, LOCALE_COLUMN, DESCRIPTION_COLUMN, + LOCAL_FILENAME_COLUMN, REMOTE_FILENAME_COLUMN, DATE_COLUMN, CHECKSUM_COLUMN, + FILESIZE_COLUMN, VERSION_COLUMN, FORMATVERSION_COLUMN, FLAGS_COLUMN, + RAW_CHECKSUM_COLUMN, RETRY_COUNT_COLUMN) + // List of all client table columns. + val CLIENT_TABLE_COLUMNS = arrayOf(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. + val DICTIONARIES_LIST_PUBLIC_COLUMNS = arrayOf(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 var sInstanceMap: TreeMap? = null + + @Synchronized + fun getInstance(context: Context?, + clientIdOrNull: String?): MetadataDbHelper { // 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. + val clientId = clientIdOrNull ?: "" + if (null == sInstanceMap) sInstanceMap = TreeMap() + var helper = sInstanceMap!![clientId] + if (null == helper) { + helper = MetadataDbHelper(context, clientId) + sInstanceMap!![clientId] = helper + } + return helper + } + + /** + * 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. + */ + fun getDb(context: Context?, clientId: String?): SQLiteDatabase { + return getInstance(context, clientId).writableDatabase + } + + private fun addRawChecksumColumnUnlessPresent(db: SQLiteDatabase) { + try { + db.execSQL("SELECT " + RAW_CHECKSUM_COLUMN + " FROM " + + METADATA_TABLE_NAME + " LIMIT 0;") + } catch (e: SQLiteException) { + Log.i(TAG, "No $RAW_CHECKSUM_COLUMN column : creating it") + db.execSQL("ALTER TABLE " + METADATA_TABLE_NAME + " ADD COLUMN " + + RAW_CHECKSUM_COLUMN + " TEXT;") + } + } + + private fun addRetryCountColumnUnlessPresent(db: SQLiteDatabase) { + try { + db.execSQL("SELECT " + RETRY_COUNT_COLUMN + " FROM " + + METADATA_TABLE_NAME + " LIMIT 0;") + } catch (e: SQLiteException) { + Log.i(TAG, "No $RETRY_COUNT_COLUMN column : creating it") + db.execSQL("ALTER TABLE " + METADATA_TABLE_NAME + " ADD COLUMN " + + RETRY_COUNT_COLUMN + " INTEGER DEFAULT " + DICTIONARY_RETRY_THRESHOLD + ";") + } + } + + /** + * 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 + */ + fun isClientKnown(context: Context?, clientId: String?): Boolean { // 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 + */ + fun getMetadataUriAsString(context: Context?, clientId: String?): String? { + val defaultDb = getDb(context, null) + val cursor = defaultDb.query(CLIENT_TABLE_NAME, arrayOf(CLIENT_METADATA_URI_COLUMN), + "$CLIENT_CLIENT_ID_COLUMN = ?", arrayOf(clientId), + null, null, null, null) + return try { + if (!cursor.moveToFirst()) null else MetadataUriGetter.getUri(context, cursor.getString(0)) + } finally { + cursor.close() + } + } + + /** + * Update the last metadata update time for all clients using a particular URI. + * + * This method searches for all clients using a particular URI and updates the last + * update time for this client. + * The current time is used as the latest update time. This saved date will be what + * is returned henceforth by [.getLastUpdateDateForClient], + * until this method is called again. + * + * @param context a context instance to open the database on + * @param uri the metadata URI we just downloaded + */ + fun saveLastUpdateTimeOfUri(context: Context?, uri: String) { + PrivateLog.log("Save last update time of URI : " + uri + " " + System.currentTimeMillis()) + val values = ContentValues() + values.put(CLIENT_LAST_UPDATE_DATE_COLUMN, System.currentTimeMillis()) + val defaultDb = getDb(context, null) + val cursor = queryClientIds(context) ?: return + try { + if (!cursor.moveToFirst()) return + do { + val clientId = cursor.getString(0) + val metadataUri = getMetadataUriAsString(context, clientId) + if (metadataUri == uri) { + defaultDb.update(CLIENT_TABLE_NAME, values, + "$CLIENT_CLIENT_ID_COLUMN = ?", arrayOf(clientId)) + } + } while (cursor.moveToNext()) + } finally { + cursor.close() + } + } + + /** + * Retrieves the last date at which we updated the metadata for this client. + * + * The returned date is in milliseconds from the EPOCH; this is the same unit as + * returned by [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. + */ + fun getLastUpdateDateForClient(context: Context?, clientId: String?): Long { + val defaultDb = getDb(context, null) + val cursor = defaultDb.query(CLIENT_TABLE_NAME, arrayOf(CLIENT_LAST_UPDATE_DATE_COLUMN), + "$CLIENT_CLIENT_ID_COLUMN = ?", arrayOf(clientId ?: ""), + null, null, null, null) + return try { + if (!cursor.moveToFirst()) 0 else cursor.getLong(0) + // Only one column, return it + } finally { + cursor.close() + } + } + + fun getOldestUpdateTime(context: Context?): Long { + val defaultDb = getDb(context, null) + val cursor = defaultDb.query(CLIENT_TABLE_NAME, arrayOf(CLIENT_LAST_UPDATE_DATE_COLUMN), + null, null, null, null, null) + return try { + if (!cursor.moveToFirst()) return 0 + val columnIndex = 0 // Only one column queried + // Initialize the earliestTime to the largest possible value. + var earliestTime = Long.MAX_VALUE // Almost 300 million years in the future + do { + val thisTime = cursor.getLong(columnIndex) + earliestTime = Math.min(thisTime, earliestTime) + } while (cursor.moveToNext()) + 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. + */ + fun makeContentValues(pendingId: Int, type: Int, + status: Int, wordlistId: String?, locale: String?, + description: String?, filename: String?, url: String?, date: Long, + rawChecksum: String?, checksum: String?, retryCount: Int, + filesize: Long, version: Int, formatVersion: Int): ContentValues { + val result = ContentValues(COLUMN_COUNT) + result.put(PENDINGID_COLUMN, pendingId) + result.put(TYPE_COLUMN, type) + result.put(WORDLISTID_COLUMN, wordlistId) + result.put(STATUS_COLUMN, status) + result.put(LOCALE_COLUMN, locale) + result.put(DESCRIPTION_COLUMN, description) + result.put(LOCAL_FILENAME_COLUMN, filename) + result.put(REMOTE_FILENAME_COLUMN, url) + result.put(DATE_COLUMN, date) + result.put(RAW_CHECKSUM_COLUMN, rawChecksum) + result.put(RETRY_COUNT_COLUMN, retryCount) + result.put(CHECKSUM_COLUMN, checksum) + result.put(FILESIZE_COLUMN, filesize) + result.put(VERSION_COLUMN, version) + result.put(FORMATVERSION_COLUMN, formatVersion) + result.put(FLAGS_COLUMN, 0) + return result + } + + /** + * Helper method to fill in an incomplete ContentValues with default values. + * A wordlist ID and a locale are required, otherwise BadFormatException is thrown. + * @return the same object that was passed in, completed with default values. + */ + @Throws(BadFormatException::class) + fun completeWithDefaultValues(result: ContentValues): ContentValues { + if (null == result[WORDLISTID_COLUMN] || null == result[LOCALE_COLUMN]) { + throw BadFormatException() + } + // 0 for the pending id, because there is none + if (null == result[PENDINGID_COLUMN]) result.put(PENDINGID_COLUMN, 0) + // This is a binary blob of a dictionary + if (null == result[TYPE_COLUMN]) result.put(TYPE_COLUMN, TYPE_BULK) + // This word list is unknown, but it's present, else we wouldn't be here, so INSTALLED + if (null == result[STATUS_COLUMN]) result.put(STATUS_COLUMN, STATUS_INSTALLED) + // No description unless specified, because we can't guess it + if (null == result[DESCRIPTION_COLUMN]) result.put(DESCRIPTION_COLUMN, "") + // File name - this is an asset, so it works as an already deleted file. +// hence, we need to supply a non-existent file name. Anything will +// do as long as it returns false when tested with File#exist(), and +// the empty string does not, so it's set to "_". + if (null == result[LOCAL_FILENAME_COLUMN]) result.put(LOCAL_FILENAME_COLUMN, "_") + // No remote file name : this can't be downloaded. Unless specified. + if (null == result[REMOTE_FILENAME_COLUMN]) result.put(REMOTE_FILENAME_COLUMN, "") + // 0 for the update date : 1970/1/1. Unless specified. + if (null == result[DATE_COLUMN]) result.put(DATE_COLUMN, 0) + // Raw checksum unknown unless specified + if (null == result[RAW_CHECKSUM_COLUMN]) result.put(RAW_CHECKSUM_COLUMN, "") + // Retry column 0 unless specified + if (null == result[RETRY_COUNT_COLUMN]) result.put(RETRY_COUNT_COLUMN, + DICTIONARY_RETRY_THRESHOLD) + // Checksum unknown unless specified + if (null == result[CHECKSUM_COLUMN]) result.put(CHECKSUM_COLUMN, "") + // No filesize unless specified + if (null == result[FILESIZE_COLUMN]) result.put(FILESIZE_COLUMN, 0) + // Smallest possible version unless specified + if (null == result[VERSION_COLUMN]) result.put(VERSION_COLUMN, 1) + // No flags unless specified + if (null == result[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 fun putStringResult(result: ContentValues, cursor: Cursor, columnId: String) { + 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 fun putIntResult(result: ContentValues, cursor: Cursor, columnId: String) { + result.put(columnId, cursor.getInt(cursor.getColumnIndex(columnId))) + } + + private fun getFirstLineAsContentValues(cursor: Cursor): ContentValues? { + val result: ContentValues? + if (cursor.moveToFirst()) { + result = ContentValues(COLUMN_COUNT) + putIntResult(result, cursor, PENDINGID_COLUMN) + putIntResult(result, cursor, TYPE_COLUMN) + putIntResult(result, cursor, STATUS_COLUMN) + putStringResult(result, cursor, WORDLISTID_COLUMN) + putStringResult(result, cursor, LOCALE_COLUMN) + putStringResult(result, cursor, DESCRIPTION_COLUMN) + putStringResult(result, cursor, LOCAL_FILENAME_COLUMN) + putStringResult(result, cursor, REMOTE_FILENAME_COLUMN) + putIntResult(result, cursor, DATE_COLUMN) + putStringResult(result, cursor, RAW_CHECKSUM_COLUMN) + putStringResult(result, cursor, CHECKSUM_COLUMN) + putIntResult(result, cursor, RETRY_COUNT_COLUMN) + putIntResult(result, cursor, FILESIZE_COLUMN) + putIntResult(result, cursor, VERSION_COLUMN) + putIntResult(result, cursor, FORMATVERSION_COLUMN) + putIntResult(result, cursor, FLAGS_COLUMN) + if (cursor.moveToNext()) { // TODO: print the second level of the stack to the log so that we know +// in which code path the error happened + Log.e(TAG, "Several SQL results when we expected only one!") + } + } else { + result = null + } + return result + } + + /** + * Gets the info about as specific download, indexed by its DownloadManager ID. + * @param db the database to get the information from. + * @param id the DownloadManager id. + * @return metadata about this download. This returns all columns in the database. + */ + fun getContentValuesByPendingId(db: SQLiteDatabase, + id: Long): ContentValues? { + val cursor = db.query(METADATA_TABLE_NAME, + METADATA_TABLE_COLUMNS, + "$PENDINGID_COLUMN= ?", arrayOf(java.lang.Long.toString(id)), + null, null, null) + ?: return null + return try { // There should never be more than one result. If because of some bug there are, +// returning only one result is the right thing to do, because we couldn't handle +// several anyway and we should still handle one. + getFirstLineAsContentValues(cursor) + } finally { + cursor.close() + } + } + + /** + * Gets the info about an installed OR deleting word list with a specified id. + * + * Basically, this is the word list that we want to return to 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. + */ + fun getInstalledOrDeletingWordListContentValuesByWordListId( + db: SQLiteDatabase, id: String?): ContentValues? { + val cursor = db.query(METADATA_TABLE_NAME, + METADATA_TABLE_COLUMNS, + "$WORDLISTID_COLUMN=? AND ($STATUS_COLUMN=? OR $STATUS_COLUMN=?)", arrayOf(id, Integer.toString(STATUS_INSTALLED), + Integer.toString(STATUS_DELETING)), + null, null, null) + ?: return null + return try { // There should only be one result, but if there are several, we can't tell which +// is the best, so we just return the first one. + getFirstLineAsContentValues(cursor) + } finally { + cursor.close() + } + } + + /** + * 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. + */ + fun getContentValuesByWordListId(db: SQLiteDatabase, + id: String?, version: Int): ContentValues? { + val cursor = db.query(METADATA_TABLE_NAME, + METADATA_TABLE_COLUMNS, + WORDLISTID_COLUMN + "= ? AND " + VERSION_COLUMN + "= ? AND " + + FORMATVERSION_COLUMN + "<= ?", arrayOf(id, + Integer.toString(version), + Integer.toString(version) + ), + null /* groupBy */, + null /* having */, + "$FORMATVERSION_COLUMN DESC" /* orderBy */) + ?: return null + return try { // This is a lookup by primary key, so there can't be more than one result. + getFirstLineAsContentValues(cursor) + } finally { + cursor.close() + } + } + + /** + * Gets the info about the latest word list with an id. + * + * @param db the database to get the information from. + * @param id the word list ID. + * @return the metadata about the word list with this id and the latest version number. + */ + fun getContentValuesOfLatestAvailableWordlistById( + db: SQLiteDatabase, id: String): ContentValues? { + val cursor = db.query(METADATA_TABLE_NAME, + METADATA_TABLE_COLUMNS, + "$WORDLISTID_COLUMN= ?", arrayOf(id), null, null, "$VERSION_COLUMN DESC", "1") + ?: return null + return try { // Return the first result from the list of results. + getFirstLineAsContentValues(cursor) + } finally { + cursor.close() + } + } + + /** + * Gets the current metadata about INSTALLED, AVAILABLE or DELETING dictionaries. + * + * This odd method is tailored to the needs of + * DictionaryProvider#getDictionaryWordListsForContentUri, which needs the word list if + * it is: + * - INSTALLED: this should be returned to LatinIME if the file is still inside the dictionary + * pack, so that it can be copied. If the file is not there, it's been copied already and should + * not be returned, so getDictionaryWordListsForContentUri takes care of this. + * - DELETING: this should be returned to LatinIME so that it can actually delete the file. + * - AVAILABLE: this should not be returned, but should be checked for auto-installation. + * + * @param context the context for getting the database. + * @param clientId the client id for retrieving the database. null for default (deprecated) + * @return a cursor with metadata about usable dictionaries. + */ + fun queryInstalledOrDeletingOrAvailableDictionaryMetadata( + context: Context?, clientId: String?): Cursor { // If clientId is null, we get the defaut DB (see #getInstance() for more about this) + return getDb(context, clientId).query(METADATA_TABLE_NAME, + METADATA_TABLE_COLUMNS, + "$STATUS_COLUMN = ? OR $STATUS_COLUMN = ? OR $STATUS_COLUMN = ?", arrayOf(Integer.toString(STATUS_INSTALLED), + Integer.toString(STATUS_DELETING), + Integer.toString(STATUS_AVAILABLE)), + null, null, LOCALE_COLUMN) + } + + /** + * 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. + */ + fun queryCurrentMetadata(context: Context?, clientId: String?): Cursor { // If clientId is null, we get the defaut DB (see #getInstance() for more about this) + return getDb(context, clientId).query(METADATA_TABLE_NAME, + METADATA_TABLE_COLUMNS, null, null, null, null, LOCALE_COLUMN) + } + + /** + * 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. + */ + fun queryDictionaries(context: Context?, clientId: String?): Cursor { // If clientId is null, we get the defaut DB (see #getInstance() for more about this) + return 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: + "$LOCALE_COLUMN != ?", arrayOf(""), // 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) + } + + /** + * 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. + */ + fun deleteClient(context: Context?, clientId: String?): Boolean { // Remove all metadata associated with this client + val 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 + val defaultDb = getDb(context, "") + return if (0 == defaultDb.delete(CLIENT_TABLE_NAME, + "$CLIENT_CLIENT_ID_COLUMN = ?", arrayOf(clientId))) { + false + } else true + } + + /** + * Updates information relative to a specific client. + * + * Updatable information includes the metadata URI and the additional ID column. It may be + * expanded in the future. + * The passed values must include a client ID in the key CLIENT_CLIENT_ID_COLUMN, and it must + * be equal to the string passed as an argument for clientId. It may not be empty. + * The passed values must also include a non-null metadata URI in the + * CLIENT_METADATA_URI_COLUMN column, as well as a non-null additional ID in the + * CLIENT_METADATA_ADDITIONAL_ID_COLUMN. Both these strings may be empty. + * If any of the above is not complied with, this function returns without updating data. + * + * @param context the context, to open the database + * @param clientId the ID of the client to update + * @param values the values to update. Must conform to the protocol (see above) + */ + fun updateClientInfo(context: Context?, clientId: String?, + values: ContentValues) { // Sanity check the content values + val valuesClientId = values.getAsString(CLIENT_CLIENT_ID_COLUMN) + val valuesMetadataUri = values.getAsString(CLIENT_METADATA_URI_COLUMN) + val valuesMetadataAdditionalId = values.getAsString(CLIENT_METADATA_ADDITIONAL_ID_COLUMN) + // Empty string is a valid client ID, but external apps may not configure it, so disallow +// both null and empty string. +// Empty string is a valid metadata URI if the client does not want updates, so allow +// empty string but disallow null. +// Empty string is a valid additional ID so allow empty string but disallow null. + if (TextUtils.isEmpty(valuesClientId) || null == valuesMetadataUri || null == valuesMetadataAdditionalId) { // We need all these columns to be filled in + DebugLogUtils.l("Missing parameter for updateClientInfo") + return + } + if (clientId != valuesClientId) { // Mismatch! The client violates the protocol. + DebugLogUtils.l("Received an updateClientInfo request for ", clientId, + " but the values " + "contain a different ID : ", valuesClientId) + return + } + // Default value for a pending ID is NOT_AN_ID + val defaultDb = getDb(context, "") + if (-1L == defaultDb.insert(CLIENT_TABLE_NAME, null, values)) { + defaultDb.update(CLIENT_TABLE_NAME, values, + "$CLIENT_CLIENT_ID_COLUMN = ?", arrayOf(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. + */ + fun queryClientIds(context: Context?): Cursor { + return getDb(context, null).query(CLIENT_TABLE_NAME, arrayOf(CLIENT_CLIENT_ID_COLUMN), null, null, null, null, null) + } + + /** + * 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. + */ + fun markEntryAsFinishedDownloadingAndInstalled(db: SQLiteDatabase, + r: ContentValues) { + when (r.getAsInteger(TYPE_COLUMN)) { + TYPE_BULK -> { + DebugLogUtils.l("Ended processing a wordlist") + // Updating a bulk word list is a three-step operation: +// - Add the new entry to the table +// - Remove the old entry from the table +// - Erase the old file +// We start by gathering the names of the files we should delete. + val filenames: MutableList = LinkedList() + val c = db.query(METADATA_TABLE_NAME, arrayOf(LOCAL_FILENAME_COLUMN), + LOCALE_COLUMN + " = ? AND " + + WORDLISTID_COLUMN + " = ? AND " + STATUS_COLUMN + " = ?", arrayOf(r.getAsString(LOCALE_COLUMN), + r.getAsString(WORDLISTID_COLUMN), + Integer.toString(STATUS_INSTALLED)), + null, null, null) + try { + if (c.moveToFirst()) { // There should never be more than one file, but if there are, it's a bug +// and we should remove them all. I think it might happen if the power of +// the phone is suddenly cut during an update. + val filenameIndex = c.getColumnIndex(LOCAL_FILENAME_COLUMN) + do { + DebugLogUtils.l("Setting for removal", c.getString(filenameIndex)) + filenames.add(c.getString(filenameIndex)) + } while (c.moveToNext()) + } + } finally { + c.close() + } + r.put(STATUS_COLUMN, STATUS_INSTALLED) + db.beginTransactionNonExclusive() + // Delete all old entries. There should never be any stalled entries, but if +// there are, this deletes them. + db.delete(METADATA_TABLE_NAME, + "$WORDLISTID_COLUMN = ?", arrayOf(r.getAsString(WORDLISTID_COLUMN))) + db.insert(METADATA_TABLE_NAME, null, r) + db.setTransactionSuccessful() + db.endTransaction() + for (filename in filenames) { + try { + val f = File(filename) + f.delete() + } catch (e: SecurityException) { // No permissions to delete. Um. Can't do anything. + } // I don't think anything else can be thrown + } + } + else -> { + } + } + } + + /** + * 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. + */ + fun deleteDownloadingEntry(db: SQLiteDatabase, id: Long) { + db.delete(METADATA_TABLE_NAME, "$PENDINGID_COLUMN = ? AND $STATUS_COLUMN = ?", arrayOf(java.lang.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. + */ + fun deleteEntry(db: SQLiteDatabase, id: String, version: Int) { + db.delete(METADATA_TABLE_NAME, "$WORDLISTID_COLUMN = ? AND $VERSION_COLUMN = ?", arrayOf(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 fun markEntryAs(db: SQLiteDatabase, id: String?, + version: Int, status: Int, downloadId: Long) { + val values = getContentValuesByWordListId(db, id, version) + values!!.put(STATUS_COLUMN, status) + if (NOT_A_DOWNLOAD_ID != downloadId) { + values.put(PENDINGID_COLUMN, downloadId) + } + db.update(METADATA_TABLE_NAME, values, + "$WORDLISTID_COLUMN = ? AND $VERSION_COLUMN = ?", arrayOf(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. + */ + fun markEntryAsEnabled(db: SQLiteDatabase, id: String?, + version: Int) { + 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. + */ + fun markEntryAsDisabled(db: SQLiteDatabase, id: String?, + version: Int) { + 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. + */ + fun markEntryAsAvailable(db: SQLiteDatabase, id: String?, + version: Int) { + 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. + */ + fun markEntryAsDownloading(db: SQLiteDatabase, id: String?, + version: Int, downloadId: Long) { + 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. + */ + fun markEntryAsDeleting(db: SQLiteDatabase, id: String?, + version: Int) { + markEntryAs(db, id, version, STATUS_DELETING, NOT_A_DOWNLOAD_ID) + } + + /** + * Checks retry counts and marks the word list as retrying if retry is possible. + * + * @param db the metadata database. + * @param id the id of the word list. + * @param version the version of the word list. + * @return `true` if the retry is possible. + */ + fun maybeMarkEntryAsRetrying(db: SQLiteDatabase, id: String?, + version: Int): Boolean { + val values = getContentValuesByWordListId(db, id, version) + val retryCount = values!!.getAsInteger(RETRY_COUNT_COLUMN) + if (retryCount > 1) { + values.put(STATUS_COLUMN, STATUS_RETRYING) + values.put(RETRY_COUNT_COLUMN, retryCount - 1) + db.update(METADATA_TABLE_NAME, values, + "$WORDLISTID_COLUMN = ? AND $VERSION_COLUMN = ?", arrayOf(id, Integer.toString(version))) + return true + } + return false + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/MetadataHandler.java b/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/MetadataHandler.java deleted file mode 100644 index 2b153fdb2..000000000 --- a/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/MetadataHandler.java +++ /dev/null @@ -1,172 +0,0 @@ -/* - * Copyright (C) 2011 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not - * use this file except in compliance with the License. You may obtain a copy of - * the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations under - * the License. - */ - -package org.dslul.openboard.inputmethod.dictionarypack; - -import android.content.ContentValues; -import android.content.Context; -import android.database.Cursor; -import android.util.Log; - -import java.io.IOException; -import java.io.InputStreamReader; -import java.util.Collections; -import java.util.ArrayList; -import java.util.List; - -/** - * Helper class to easy up manipulation of dictionary pack metadata. - */ -public class MetadataHandler { - - public static final String TAG = MetadataHandler.class.getSimpleName(); - - // The canonical file name for metadata. This is not the name of a real file on the - // device, but a symbolic name used in the database and in metadata handling. It is never - // tested against, only used for human-readability as the file name for the metadata. - public static final String METADATA_FILENAME = "metadata.json"; - - /** - * Reads the data from the cursor and store it in metadata objects. - * @param results the cursor to read data from. - * @return the constructed list of wordlist metadata. - */ - private static List makeMetadataObject(final Cursor results) { - final ArrayList buildingMetadata = new ArrayList<>(); - if (null != results && results.moveToFirst()) { - final int localeColumn = results.getColumnIndex(MetadataDbHelper.LOCALE_COLUMN); - final int typeColumn = results.getColumnIndex(MetadataDbHelper.TYPE_COLUMN); - final int descriptionColumn = - results.getColumnIndex(MetadataDbHelper.DESCRIPTION_COLUMN); - final int idIndex = results.getColumnIndex(MetadataDbHelper.WORDLISTID_COLUMN); - final int updateIndex = results.getColumnIndex(MetadataDbHelper.DATE_COLUMN); - final int fileSizeIndex = results.getColumnIndex(MetadataDbHelper.FILESIZE_COLUMN); - final int rawChecksumIndex = - results.getColumnIndex(MetadataDbHelper.RAW_CHECKSUM_COLUMN); - final int checksumIndex = results.getColumnIndex(MetadataDbHelper.CHECKSUM_COLUMN); - final int retryCountIndex = results.getColumnIndex(MetadataDbHelper.RETRY_COUNT_COLUMN); - final int localFilenameIndex = - results.getColumnIndex(MetadataDbHelper.LOCAL_FILENAME_COLUMN); - final int remoteFilenameIndex = - results.getColumnIndex(MetadataDbHelper.REMOTE_FILENAME_COLUMN); - final int versionIndex = results.getColumnIndex(MetadataDbHelper.VERSION_COLUMN); - final int formatVersionIndex = - results.getColumnIndex(MetadataDbHelper.FORMATVERSION_COLUMN); - do { - buildingMetadata.add(new WordListMetadata(results.getString(idIndex), - results.getInt(typeColumn), - results.getString(descriptionColumn), - results.getLong(updateIndex), - results.getLong(fileSizeIndex), - results.getString(rawChecksumIndex), - results.getString(checksumIndex), - results.getInt(retryCountIndex), - results.getString(localFilenameIndex), - results.getString(remoteFilenameIndex), - results.getInt(versionIndex), - results.getInt(formatVersionIndex), - 0, results.getString(localeColumn))); - } while (results.moveToNext()); - } - return Collections.unmodifiableList(buildingMetadata); - } - - /** - * Gets the whole metadata, for installed and not installed dictionaries. - * @param context The context to open files over. - * @param clientId the client id for retrieving the database. null for default (deprecated) - * @return The current metadata. - */ - public static List getCurrentMetadata(final Context context, - final String clientId) { - // If clientId is null, we get a cursor on the default database (see - // MetadataDbHelper#getInstance() for more on this) - final Cursor results = MetadataDbHelper.queryCurrentMetadata(context, clientId); - // If null, we should return makeMetadataObject(null), so we go through. - try { - return makeMetadataObject(results); - } finally { - if (null != results) { - results.close(); - } - } - } - - /** - * Gets the metadata, for a specific dictionary. - * - * @param context The context to open files over. - * @param clientId the client id for retrieving the database. null for default (deprecated). - * @param wordListId the word list ID. - * @param version the word list version. - * @return the current metaData - */ - public static WordListMetadata getCurrentMetadataForWordList(final Context context, - final String clientId, final String wordListId, final int version) { - final ContentValues contentValues = MetadataDbHelper.getContentValuesByWordListId( - MetadataDbHelper.getDb(context, clientId), wordListId, version); - if (contentValues == null) { - // TODO: Figure out why this would happen. - // Check if this happens when the metadata gets updated in the background. - Log.e(TAG, String.format( "Unable to find the current metadata for wordlist " - + "(clientId=%s, wordListId=%s, version=%d) on the database", - clientId, wordListId, version)); - return null; - } - return WordListMetadata.createFromContentValues(contentValues); - } - - /** - * Read metadata from a stream. - * @param input The stream to read from. - * @return The read metadata. - * @throws IOException if the input stream cannot be read - * @throws BadFormatException if the stream is not in a known format - */ - public static List 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 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 > bestFormatVersion) { - bestWordList = wordList; - bestFormatVersion = wordList.mFormatVersion; - } - } - // If we didn't find any match we'll return null. - return bestWordList; - } -} diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/MetadataHandler.kt b/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/MetadataHandler.kt new file mode 100644 index 000000000..e24481b62 --- /dev/null +++ b/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/MetadataHandler.kt @@ -0,0 +1,156 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package org.dslul.openboard.inputmethod.dictionarypack + +import android.content.ContentValues +import android.content.Context +import android.database.Cursor +import android.util.Log +import java.io.IOException +import java.io.InputStreamReader +import java.util.* + +/** + * Helper class to easy up manipulation of dictionary pack metadata. + */ +object MetadataHandler { + val TAG = MetadataHandler::class.java.simpleName + // 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. + const val 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 fun makeMetadataObject(results: Cursor?): List { + val buildingMetadata = ArrayList() + if (null != results && results.moveToFirst()) { + val localeColumn = results.getColumnIndex(MetadataDbHelper.Companion.LOCALE_COLUMN) + val typeColumn = results.getColumnIndex(MetadataDbHelper.Companion.TYPE_COLUMN) + val descriptionColumn = results.getColumnIndex(MetadataDbHelper.Companion.DESCRIPTION_COLUMN) + val idIndex = results.getColumnIndex(MetadataDbHelper.Companion.WORDLISTID_COLUMN) + val updateIndex = results.getColumnIndex(MetadataDbHelper.Companion.DATE_COLUMN) + val fileSizeIndex = results.getColumnIndex(MetadataDbHelper.Companion.FILESIZE_COLUMN) + val rawChecksumIndex = results.getColumnIndex(MetadataDbHelper.Companion.RAW_CHECKSUM_COLUMN) + val checksumIndex = results.getColumnIndex(MetadataDbHelper.Companion.CHECKSUM_COLUMN) + val retryCountIndex = results.getColumnIndex(MetadataDbHelper.Companion.RETRY_COUNT_COLUMN) + val localFilenameIndex = results.getColumnIndex(MetadataDbHelper.Companion.LOCAL_FILENAME_COLUMN) + val remoteFilenameIndex = results.getColumnIndex(MetadataDbHelper.Companion.REMOTE_FILENAME_COLUMN) + val versionIndex = results.getColumnIndex(MetadataDbHelper.Companion.VERSION_COLUMN) + val formatVersionIndex = results.getColumnIndex(MetadataDbHelper.Companion.FORMATVERSION_COLUMN) + do { + buildingMetadata.add(WordListMetadata(results.getString(idIndex), + results.getInt(typeColumn), + results.getString(descriptionColumn), + results.getLong(updateIndex), + results.getLong(fileSizeIndex), + results.getString(rawChecksumIndex), + results.getString(checksumIndex), + results.getInt(retryCountIndex), + results.getString(localFilenameIndex), + results.getString(remoteFilenameIndex), + results.getInt(versionIndex), + results.getInt(formatVersionIndex), + 0, results.getString(localeColumn))) + } while (results.moveToNext()) + } + return Collections.unmodifiableList(buildingMetadata) + } + + /** + * Gets the whole metadata, for installed and not installed dictionaries. + * @param context The context to open files over. + * @param clientId the client id for retrieving the database. null for default (deprecated) + * @return The current metadata. + */ + fun getCurrentMetadata(context: Context?, + clientId: String?): List { // If clientId is null, we get a cursor on the default database (see +// MetadataDbHelper#getInstance() for more on this) + val results: Cursor = MetadataDbHelper.Companion.queryCurrentMetadata(context, clientId) + // If null, we should return makeMetadataObject(null), so we go through. + return try { + makeMetadataObject(results) + } finally { + results?.close() + } + } + + /** + * Gets the metadata, for a specific dictionary. + * + * @param context The context to open files over. + * @param clientId the client id for retrieving the database. null for default (deprecated). + * @param wordListId the word list ID. + * @param version the word list version. + * @return the current metaData + */ + fun getCurrentMetadataForWordList(context: Context?, + clientId: String?, wordListId: String?, version: Int): WordListMetadata? { + val contentValues: ContentValues = MetadataDbHelper.Companion.getContentValuesByWordListId( + MetadataDbHelper.Companion.getDb(context, clientId), wordListId, version)!! + if (contentValues == null) { // TODO: Figure out why this would happen. +// Check if this happens when the metadata gets updated in the background. + Log.e(TAG, String.format("Unable to find the current metadata for wordlist " + + "(clientId=%s, wordListId=%s, version=%d) on the database", + clientId, wordListId, version)) + return null + } + return WordListMetadata.Companion.createFromContentValues(contentValues) + } + + /** + * Read metadata from a stream. + * @param input The stream to read from. + * @return The read metadata. + * @throws IOException if the input stream cannot be read + * @throws BadFormatException if the stream is not in a known format + */ + @Throws(IOException::class, BadFormatException::class) + fun readMetadata(input: InputStreamReader?): List? { + 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. + */ + fun findWordListById(metadata: List, + id: String): WordListMetadata? { + var bestWordList: WordListMetadata? = null + var bestFormatVersion = Int.MIN_VALUE // To be sure we can't be inadvertently smaller + for (wordList in metadata) { + if (id == wordList.mId && wordList.mFormatVersion > bestFormatVersion) { + bestWordList = wordList + bestFormatVersion = wordList.mFormatVersion + } + } + // If we didn't find any match we'll return null. + return bestWordList + } +} \ No newline at end of file diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/MetadataParser.java b/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/MetadataParser.java deleted file mode 100644 index 378f9ab65..000000000 --- a/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/MetadataParser.java +++ /dev/null @@ -1,114 +0,0 @@ -/* - * Copyright (C) 2011 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not - * use this file except in compliance with the License. You may obtain a copy of - * the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations under - * the License. - */ - -package org.dslul.openboard.inputmethod.dictionarypack; - -import android.text.TextUtils; -import android.util.JsonReader; - -import java.io.IOException; -import java.io.InputStreamReader; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.TreeMap; - -/** - * Helper class containing functions to parse the dictionary metadata. - */ -public class MetadataParser { - - // Name of the fields in the JSON-formatted file. - private static final String ID_FIELD_NAME = MetadataDbHelper.WORDLISTID_COLUMN; - private static final String LOCALE_FIELD_NAME = "locale"; - private static final String DESCRIPTION_FIELD_NAME = MetadataDbHelper.DESCRIPTION_COLUMN; - private static final String UPDATE_FIELD_NAME = "update"; - private static final String FILESIZE_FIELD_NAME = MetadataDbHelper.FILESIZE_COLUMN; - private static final String RAW_CHECKSUM_FIELD_NAME = MetadataDbHelper.RAW_CHECKSUM_COLUMN; - private static final String CHECKSUM_FIELD_NAME = MetadataDbHelper.CHECKSUM_COLUMN; - private static final String REMOTE_FILENAME_FIELD_NAME = - MetadataDbHelper.REMOTE_FILENAME_COLUMN; - private static final String VERSION_FIELD_NAME = MetadataDbHelper.VERSION_COLUMN; - private static final String FORMATVERSION_FIELD_NAME = MetadataDbHelper.FORMATVERSION_COLUMN; - - /** - * Parse one JSON-formatted word list metadata. - * @param reader the reader containing the data. - * @return a WordListMetadata object from the parsed data. - * @throws IOException if the underlying reader throws IOException during reading. - */ - private static WordListMetadata parseOneWordList(final JsonReader reader) - throws IOException, BadFormatException { - final TreeMap arguments = new TreeMap<>(); - reader.beginObject(); - while (reader.hasNext()) { - final String name = reader.nextName(); - if (!TextUtils.isEmpty(name)) { - arguments.put(name, reader.nextString()); - } - } - reader.endObject(); - if (TextUtils.isEmpty(arguments.get(ID_FIELD_NAME)) - || TextUtils.isEmpty(arguments.get(LOCALE_FIELD_NAME)) - || TextUtils.isEmpty(arguments.get(DESCRIPTION_FIELD_NAME)) - || TextUtils.isEmpty(arguments.get(UPDATE_FIELD_NAME)) - || TextUtils.isEmpty(arguments.get(FILESIZE_FIELD_NAME)) - || TextUtils.isEmpty(arguments.get(CHECKSUM_FIELD_NAME)) - || TextUtils.isEmpty(arguments.get(REMOTE_FILENAME_FIELD_NAME)) - || TextUtils.isEmpty(arguments.get(VERSION_FIELD_NAME)) - || TextUtils.isEmpty(arguments.get(FORMATVERSION_FIELD_NAME))) { - throw new BadFormatException(arguments.toString()); - } - // TODO: need to find out whether it's bulk or update - // The null argument is the local file name, which is not known at this time and will - // be decided later. - return new WordListMetadata( - arguments.get(ID_FIELD_NAME), - MetadataDbHelper.TYPE_BULK, - arguments.get(DESCRIPTION_FIELD_NAME), - Long.parseLong(arguments.get(UPDATE_FIELD_NAME)), - Long.parseLong(arguments.get(FILESIZE_FIELD_NAME)), - arguments.get(RAW_CHECKSUM_FIELD_NAME), - arguments.get(CHECKSUM_FIELD_NAME), - MetadataDbHelper.DICTIONARY_RETRY_THRESHOLD /* retryCount */, - null, - arguments.get(REMOTE_FILENAME_FIELD_NAME), - Integer.parseInt(arguments.get(VERSION_FIELD_NAME)), - Integer.parseInt(arguments.get(FORMATVERSION_FIELD_NAME)), - 0, arguments.get(LOCALE_FIELD_NAME)); - } - - /** - * Parses metadata in the JSON format. - * @param input a stream reader expected to contain JSON formatted metadata. - * @return dictionary metadata, as an array of WordListMetadata objects. - * @throws IOException if the underlying reader throws IOException during reading. - * @throws BadFormatException if the data was not in the expected format. - */ - public static List parseMetadata(final InputStreamReader input) - throws IOException, BadFormatException { - JsonReader reader = new JsonReader(input); - final ArrayList readInfo = new ArrayList<>(); - reader.beginArray(); - while (reader.hasNext()) { - final WordListMetadata thisMetadata = parseOneWordList(reader); - if (!TextUtils.isEmpty(thisMetadata.mLocale)) - readInfo.add(thisMetadata); - } - return Collections.unmodifiableList(readInfo); - } - -} diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/MetadataParser.kt b/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/MetadataParser.kt new file mode 100644 index 000000000..a4390045a --- /dev/null +++ b/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/MetadataParser.kt @@ -0,0 +1,100 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package org.dslul.openboard.inputmethod.dictionarypack + +import android.text.TextUtils +import android.util.JsonReader +import java.io.IOException +import java.io.InputStreamReader +import java.util.* + +/** + * Helper class containing functions to parse the dictionary metadata. + */ +object MetadataParser { + // Name of the fields in the JSON-formatted file. + private val ID_FIELD_NAME: String = MetadataDbHelper.Companion.WORDLISTID_COLUMN + private const val LOCALE_FIELD_NAME = "locale" + private val DESCRIPTION_FIELD_NAME: String = MetadataDbHelper.Companion.DESCRIPTION_COLUMN + private const val UPDATE_FIELD_NAME = "update" + private val FILESIZE_FIELD_NAME: String = MetadataDbHelper.Companion.FILESIZE_COLUMN + private val RAW_CHECKSUM_FIELD_NAME: String = MetadataDbHelper.Companion.RAW_CHECKSUM_COLUMN + private val CHECKSUM_FIELD_NAME: String = MetadataDbHelper.Companion.CHECKSUM_COLUMN + private val REMOTE_FILENAME_FIELD_NAME: String = MetadataDbHelper.Companion.REMOTE_FILENAME_COLUMN + private val VERSION_FIELD_NAME: String = MetadataDbHelper.Companion.VERSION_COLUMN + private val FORMATVERSION_FIELD_NAME: String = MetadataDbHelper.Companion.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. + */ + @Throws(IOException::class, BadFormatException::class) + private fun parseOneWordList(reader: JsonReader): WordListMetadata { + val arguments = TreeMap() + reader.beginObject() + while (reader.hasNext()) { + val name = reader.nextName() + if (!TextUtils.isEmpty(name)) { + arguments[name] = reader.nextString() + } + } + reader.endObject() + if (TextUtils.isEmpty(arguments[ID_FIELD_NAME]) + || TextUtils.isEmpty(arguments[LOCALE_FIELD_NAME]) + || TextUtils.isEmpty(arguments[DESCRIPTION_FIELD_NAME]) + || TextUtils.isEmpty(arguments[UPDATE_FIELD_NAME]) + || TextUtils.isEmpty(arguments[FILESIZE_FIELD_NAME]) + || TextUtils.isEmpty(arguments[CHECKSUM_FIELD_NAME]) + || TextUtils.isEmpty(arguments[REMOTE_FILENAME_FIELD_NAME]) + || TextUtils.isEmpty(arguments[VERSION_FIELD_NAME]) + || TextUtils.isEmpty(arguments[FORMATVERSION_FIELD_NAME])) { + throw 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 WordListMetadata( + arguments[ID_FIELD_NAME], + MetadataDbHelper.Companion.TYPE_BULK, + arguments[DESCRIPTION_FIELD_NAME], arguments[UPDATE_FIELD_NAME]!!.toLong(), arguments[FILESIZE_FIELD_NAME]!!.toLong(), + arguments[RAW_CHECKSUM_FIELD_NAME], + arguments[CHECKSUM_FIELD_NAME], + MetadataDbHelper.Companion.DICTIONARY_RETRY_THRESHOLD /* retryCount */, + null, + arguments[REMOTE_FILENAME_FIELD_NAME], arguments[VERSION_FIELD_NAME]!!.toInt(), arguments[FORMATVERSION_FIELD_NAME]!!.toInt(), + 0, arguments[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. + */ + @Throws(IOException::class, BadFormatException::class) + fun parseMetadata(input: InputStreamReader?): List { + val reader = JsonReader(input) + val readInfo = ArrayList() + reader.beginArray() + while (reader.hasNext()) { + val thisMetadata = parseOneWordList(reader) + if (!TextUtils.isEmpty(thisMetadata.mLocale)) readInfo.add(thisMetadata) + } + return Collections.unmodifiableList(readInfo) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/MetadataUriGetter.java b/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/MetadataUriGetter.kt similarity index 72% rename from app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/MetadataUriGetter.java rename to app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/MetadataUriGetter.kt index f3c460d6b..ef4448b43 100644 --- a/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/MetadataUriGetter.java +++ b/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/MetadataUriGetter.kt @@ -13,17 +13,15 @@ * License for the specific language governing permissions and limitations under * the License. */ +package org.dslul.openboard.inputmethod.dictionarypack -package org.dslul.openboard.inputmethod.dictionarypack; - -import android.content.Context; +import android.content.Context /** * Helper to get the metadata URI from its base URI. */ -@SuppressWarnings("unused") -public class MetadataUriGetter { - public static String getUri(final Context context, final String baseUri) { - return baseUri; +object MetadataUriGetter { + fun getUri(context: Context?, baseUri: String): String { + return baseUri } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/PrivateLog.java b/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/PrivateLog.java deleted file mode 100644 index 80d31feb7..000000000 --- a/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/PrivateLog.java +++ /dev/null @@ -1,102 +0,0 @@ -/* - * Copyright (C) 2011 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not - * use this file except in compliance with the License. You may obtain a copy of - * the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations under - * the License. - */ - -package org.dslul.openboard.inputmethod.dictionarypack; - -import android.content.ContentValues; -import android.content.Context; -import android.database.sqlite.SQLiteDatabase; -import android.database.sqlite.SQLiteOpenHelper; - -import java.text.SimpleDateFormat; -import java.util.Date; -import java.util.Locale; - -/** - * Class to keep long-term log. This is inactive in production, and is only for debug purposes. - */ -public class PrivateLog { - - public static final boolean DEBUG = false; - - private static final String LOG_DATABASE_NAME = "log"; - private static final String LOG_TABLE_NAME = "log"; - private static final int LOG_DATABASE_VERSION = 1; - - private static final String COLUMN_DATE = "date"; - private static final String COLUMN_EVENT = "event"; - - private static final String LOG_TABLE_CREATE = "CREATE TABLE " + LOG_TABLE_NAME + " (" - + COLUMN_DATE + " TEXT," - + COLUMN_EVENT + " TEXT);"; - - static final SimpleDateFormat sDateFormat = new SimpleDateFormat( - "yyyy/MM/dd HH:mm:ss", Locale.ROOT); - - private static PrivateLog sInstance = new PrivateLog(); - private static DebugHelper sDebugHelper = null; - - private PrivateLog() { - } - - public static synchronized PrivateLog getInstance(final Context context) { - if (!DEBUG) return sInstance; - synchronized(PrivateLog.class) { - if (sDebugHelper == null) { - sDebugHelper = new DebugHelper(context); - } - return sInstance; - } - } - - static class DebugHelper extends SQLiteOpenHelper { - - DebugHelper(final Context context) { - super(context, LOG_DATABASE_NAME, null, LOG_DATABASE_VERSION); - } - - @Override - public void onCreate(SQLiteDatabase db) { - if (!DEBUG) return; - db.execSQL(LOG_TABLE_CREATE); - insert(db, "Created table"); - } - - @Override - public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { - if (!DEBUG) return; - // Remove all data. - db.execSQL("DROP TABLE IF EXISTS " + LOG_TABLE_NAME); - onCreate(db); - insert(db, "Upgrade finished"); - } - - static void insert(SQLiteDatabase db, String event) { - if (!DEBUG) return; - final ContentValues c = new ContentValues(2); - c.put(COLUMN_DATE, sDateFormat.format(new Date(System.currentTimeMillis()))); - c.put(COLUMN_EVENT, event); - db.insert(LOG_TABLE_NAME, null, c); - } - - } - - public static void log(String event) { - if (!DEBUG) return; - final SQLiteDatabase l = sDebugHelper.getWritableDatabase(); - DebugHelper.insert(l, event); - } -} diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/PrivateLog.kt b/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/PrivateLog.kt new file mode 100644 index 000000000..c7ed4aafa --- /dev/null +++ b/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/PrivateLog.kt @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package org.dslul.openboard.inputmethod.dictionarypack + +import android.content.ContentValues +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteOpenHelper +import java.text.SimpleDateFormat +import java.util.* + +/** + * Class to keep long-term log. This is inactive in production, and is only for debug purposes. + */ +object PrivateLog { + const val DEBUG = false + private const val LOG_DATABASE_NAME = "log" + private const val LOG_TABLE_NAME = "log" + private const val LOG_DATABASE_VERSION = 1 + private const val COLUMN_DATE = "date" + private const val COLUMN_EVENT = "event" + private const val LOG_TABLE_CREATE = ("CREATE TABLE " + LOG_TABLE_NAME + " (" + + COLUMN_DATE + " TEXT," + + COLUMN_EVENT + " TEXT);") + val sDateFormat = SimpleDateFormat( + "yyyy/MM/dd HH:mm:ss", Locale.ROOT) + private val sInstance: PrivateLog = PrivateLog + private var sDebugHelper: DebugHelper? = null + @Synchronized + fun getInstance(context: Context?): PrivateLog { + if (!DEBUG) return sInstance + synchronized(PrivateLog::class.java) { + if (sDebugHelper == null) { + sDebugHelper = DebugHelper(context) + } + return sInstance + } + } + + fun log(event: String?) { + if (!DEBUG) return + val l = sDebugHelper!!.writableDatabase + DebugHelper.insert(l, event) + } + + internal class DebugHelper(context: Context?) : SQLiteOpenHelper(context, LOG_DATABASE_NAME, null, LOG_DATABASE_VERSION) { + override fun onCreate(db: SQLiteDatabase) { + if (!DEBUG) return + db.execSQL(LOG_TABLE_CREATE) + insert(db, "Created table") + } + + override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + if (!DEBUG) return + // Remove all data. + db.execSQL("DROP TABLE IF EXISTS $LOG_TABLE_NAME") + onCreate(db) + insert(db, "Upgrade finished") + } + + companion object { + fun insert(db: SQLiteDatabase, event: String?) { + if (!DEBUG) return + val c = ContentValues(2) + c.put(COLUMN_DATE, sDateFormat.format(Date(System.currentTimeMillis()))) + c.put(COLUMN_EVENT, event) + db.insert(LOG_TABLE_NAME, null, c) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/ProblemReporter.java b/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/ProblemReporter.kt similarity index 83% rename from app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/ProblemReporter.java rename to app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/ProblemReporter.kt index eb52c13fa..0f6c8ff2a 100644 --- a/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/ProblemReporter.java +++ b/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/ProblemReporter.kt @@ -13,12 +13,11 @@ * License for the specific language governing permissions and limitations under * the License. */ - -package org.dslul.openboard.inputmethod.dictionarypack; +package org.dslul.openboard.inputmethod.dictionarypack /** * A simple interface to report problems. */ -public interface ProblemReporter { - public void report(Exception e); -} +interface ProblemReporter { + fun report(e: Exception?) +} \ No newline at end of file diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/WordListMetadata.java b/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/WordListMetadata.java deleted file mode 100644 index a0999c6ac..000000000 --- a/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/WordListMetadata.java +++ /dev/null @@ -1,135 +0,0 @@ -/* - * Copyright (C) 2011 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not - * use this file except in compliance with the License. You may obtain a copy of - * the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations under - * the License. - */ - -package org.dslul.openboard.inputmethod.dictionarypack; - -import android.content.ContentValues; - -import javax.annotation.Nonnull; - -/** - * The metadata for a single word list. - * - * Instances of this class are always immutable. - */ -public class WordListMetadata { - - public final String mId; - public final int mType; // Type, as of MetadataDbHelper#TYPE_* - public final String mDescription; - public final long mLastUpdate; - public final long mFileSize; - public final String mRawChecksum; - public final String mChecksum; - public final String mLocalFilename; - public final String mRemoteFilename; - public final int mVersion; // version of this word list - public final int mFlags; // Always 0 in this version, reserved for future use - public int mRetryCount; - - // The locale is matched against the locale requested by the client. The matching algorithm - // is a standard locale matching with fallback; it is implemented in - // DictionaryProvider#getDictionaryFileForContentUri. - public final String mLocale; - - - // Version number of the format. - // This implementation of the DictionaryDataService knows how to handle format 1 only. - // This is only for forward compatibility, to be able to upgrade the format without - // breaking old implementations. - public final int mFormatVersion; - - public WordListMetadata(final String id, final int type, - final String description, final long lastUpdate, final long fileSize, - final String rawChecksum, final String checksum, final int retryCount, - final String localFilename, final String remoteFilename, - final int version, final int formatVersion, - final int flags, final String locale) { - mId = id; - mType = type; - mDescription = description; - mLastUpdate = lastUpdate; // In milliseconds - mFileSize = fileSize; - mRawChecksum = rawChecksum; - mChecksum = checksum; - mRetryCount = retryCount; - mLocalFilename = localFilename; - mRemoteFilename = remoteFilename; - mVersion = version; - mFormatVersion = formatVersion; - mFlags = flags; - mLocale = locale; - } - - /** - * Create a WordListMetadata from the contents of a ContentValues. - * - * If this lacks any required field, IllegalArgumentException is thrown. - */ - public static WordListMetadata createFromContentValues(@Nonnull final ContentValues values) { - final String id = values.getAsString(MetadataDbHelper.WORDLISTID_COLUMN); - final Integer type = values.getAsInteger(MetadataDbHelper.TYPE_COLUMN); - final String description = values.getAsString(MetadataDbHelper.DESCRIPTION_COLUMN); - final Long lastUpdate = values.getAsLong(MetadataDbHelper.DATE_COLUMN); - final Long fileSize = values.getAsLong(MetadataDbHelper.FILESIZE_COLUMN); - final String rawChecksum = values.getAsString(MetadataDbHelper.RAW_CHECKSUM_COLUMN); - final String checksum = values.getAsString(MetadataDbHelper.CHECKSUM_COLUMN); - final int retryCount = values.getAsInteger(MetadataDbHelper.RETRY_COUNT_COLUMN); - final String localFilename = values.getAsString(MetadataDbHelper.LOCAL_FILENAME_COLUMN); - final String remoteFilename = values.getAsString(MetadataDbHelper.REMOTE_FILENAME_COLUMN); - final Integer version = values.getAsInteger(MetadataDbHelper.VERSION_COLUMN); - final Integer formatVersion = values.getAsInteger(MetadataDbHelper.FORMATVERSION_COLUMN); - final Integer flags = values.getAsInteger(MetadataDbHelper.FLAGS_COLUMN); - final String locale = values.getAsString(MetadataDbHelper.LOCALE_COLUMN); - if (null == id - || null == type - || null == description - || null == lastUpdate - || null == fileSize - || null == checksum - || null == localFilename - || null == remoteFilename - || null == version - || null == formatVersion - || null == flags - || null == locale) { - throw new IllegalArgumentException(); - } - return new WordListMetadata(id, type, description, lastUpdate, fileSize, rawChecksum, - checksum, retryCount, localFilename, remoteFilename, version, formatVersion, - flags, locale); - } - - @Override - public String toString() { - final StringBuilder sb = new StringBuilder(WordListMetadata.class.getSimpleName()); - sb.append(" : ").append(mId); - sb.append("\nType : ").append(mType); - sb.append("\nDescription : ").append(mDescription); - sb.append("\nLastUpdate : ").append(mLastUpdate); - sb.append("\nFileSize : ").append(mFileSize); - sb.append("\nRawChecksum : ").append(mRawChecksum); - sb.append("\nChecksum : ").append(mChecksum); - sb.append("\nRetryCount: ").append(mRetryCount); - sb.append("\nLocalFilename : ").append(mLocalFilename); - sb.append("\nRemoteFilename : ").append(mRemoteFilename); - sb.append("\nVersion : ").append(mVersion); - sb.append("\nFormatVersion : ").append(mFormatVersion); - sb.append("\nFlags : ").append(mFlags); - sb.append("\nLocale : ").append(mLocale); - return sb.toString(); - } -} diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/WordListMetadata.kt b/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/WordListMetadata.kt new file mode 100644 index 000000000..2d998e1fe --- /dev/null +++ b/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/WordListMetadata.kt @@ -0,0 +1,90 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package org.dslul.openboard.inputmethod.dictionarypack + +import android.content.ContentValues + +/** + * The metadata for a single word list. + * + * Instances of this class are always immutable. + */ +class WordListMetadata // In milliseconds +(val mId: String?, // Type, as of MetadataDbHelper#TYPE_* + val mType: Int, + val mDescription: String?, val mLastUpdate: Long, val mFileSize: Long, + val mRawChecksum: String?, val mChecksum: String?, var mRetryCount: Int, + val mLocalFilename: String?, val mRemoteFilename: String?, + // version of this word list + val mVersion: Int, // 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. + val mFormatVersion: Int, + // Always 0 in this version, reserved for future use + val mFlags: Int, // 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. + val mLocale: String?) { + + override fun toString(): String { + val sb = StringBuilder(WordListMetadata::class.java.simpleName) + sb.append(" : ").append(mId) + sb.append("\nType : ").append(mType) + sb.append("\nDescription : ").append(mDescription) + sb.append("\nLastUpdate : ").append(mLastUpdate) + sb.append("\nFileSize : ").append(mFileSize) + sb.append("\nRawChecksum : ").append(mRawChecksum) + sb.append("\nChecksum : ").append(mChecksum) + sb.append("\nRetryCount: ").append(mRetryCount) + sb.append("\nLocalFilename : ").append(mLocalFilename) + sb.append("\nRemoteFilename : ").append(mRemoteFilename) + sb.append("\nVersion : ").append(mVersion) + sb.append("\nFormatVersion : ").append(mFormatVersion) + sb.append("\nFlags : ").append(mFlags) + sb.append("\nLocale : ").append(mLocale) + return sb.toString() + } + + companion object { + /** + * Create a WordListMetadata from the contents of a ContentValues. + * + * If this lacks any required field, IllegalArgumentException is thrown. + */ + fun createFromContentValues(values: ContentValues): WordListMetadata { + val id = values.getAsString(MetadataDbHelper.Companion.WORDLISTID_COLUMN) + val type = values.getAsInteger(MetadataDbHelper.Companion.TYPE_COLUMN) + val description = values.getAsString(MetadataDbHelper.Companion.DESCRIPTION_COLUMN) + val lastUpdate = values.getAsLong(MetadataDbHelper.Companion.DATE_COLUMN) + val fileSize = values.getAsLong(MetadataDbHelper.Companion.FILESIZE_COLUMN) + val rawChecksum = values.getAsString(MetadataDbHelper.Companion.RAW_CHECKSUM_COLUMN) + val checksum = values.getAsString(MetadataDbHelper.Companion.CHECKSUM_COLUMN) + val retryCount = values.getAsInteger(MetadataDbHelper.Companion.RETRY_COUNT_COLUMN) + val localFilename = values.getAsString(MetadataDbHelper.Companion.LOCAL_FILENAME_COLUMN) + val remoteFilename = values.getAsString(MetadataDbHelper.Companion.REMOTE_FILENAME_COLUMN) + val version = values.getAsInteger(MetadataDbHelper.Companion.VERSION_COLUMN) + val formatVersion = values.getAsInteger(MetadataDbHelper.Companion.FORMATVERSION_COLUMN) + val flags = values.getAsInteger(MetadataDbHelper.Companion.FLAGS_COLUMN) + val locale = values.getAsString(MetadataDbHelper.Companion.LOCALE_COLUMN) + require(!(null == id || null == type || null == description || null == lastUpdate || null == fileSize || null == checksum || null == localFilename || null == remoteFilename || null == version || null == formatVersion || null == flags || null == locale)) + return WordListMetadata(id, type, description, lastUpdate, fileSize, rawChecksum, + checksum, retryCount, localFilename, remoteFilename, version, formatVersion, + flags, locale) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/WordListPreference.java b/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/WordListPreference.java deleted file mode 100644 index ae3572bc6..000000000 --- a/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/WordListPreference.java +++ /dev/null @@ -1,296 +0,0 @@ -/** - * Copyright (C) 2011 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not - * use this file except in compliance with the License. You may obtain a copy - * of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations - * under the License. - */ - -package org.dslul.openboard.inputmethod.dictionarypack; - -import android.content.Context; -import android.content.SharedPreferences; -import android.preference.Preference; -import android.util.Log; -import android.view.View; -import android.view.ViewGroup; -import android.view.ViewParent; -import android.widget.ListView; -import android.widget.TextView; - -import org.dslul.openboard.inputmethod.latin.R; - -import java.util.Locale; - -/** - * A preference for one word list. - * - * This preference refers to a single word list, as available in the dictionary - * pack. Upon being pressed, it displays a menu to allow the user to install, disable, - * enable or delete it as appropriate for the current state of the word list. - */ -public final class WordListPreference extends Preference { - private static final String TAG = WordListPreference.class.getSimpleName(); - - // What to display in the "status" field when we receive unknown data as a status from - // the content provider. Empty string sounds sensible. - private static final String NO_STATUS_MESSAGE = ""; - - /// Actions - private static final int ACTION_UNKNOWN = 0; - private static final int ACTION_ENABLE_DICT = 1; - private static final int ACTION_DISABLE_DICT = 2; - private static final int ACTION_DELETE_DICT = 3; - - // Members - // The metadata word list id and version of this word list. - public final String mWordlistId; - public final int mVersion; - public final Locale mLocale; - public final String mDescription; - - // The id of the client for which this preference is. - private final String mClientId; - // The status - private int mStatus; - // The size of the dictionary file - private final int mFilesize; - - private final DictionaryListInterfaceState mInterfaceState; - - public WordListPreference(final Context context, - final DictionaryListInterfaceState dictionaryListInterfaceState, final String clientId, - final String wordlistId, final int version, final Locale locale, - final String description, final int status, final int filesize) { - super(context, null); - mInterfaceState = dictionaryListInterfaceState; - mClientId = clientId; - mVersion = version; - mWordlistId = wordlistId; - mFilesize = filesize; - mLocale = locale; - mDescription = description; - - setLayoutResource(R.layout.dictionary_line); - - setTitle(description); - setStatus(status); - setKey(wordlistId); - } - - public void setStatus(final int status) { - if (status == mStatus) return; - mStatus = status; - setSummary(getSummary(status)); - } - - public boolean hasStatus(final int status) { - return status == mStatus; - } - - @Override - public View onCreateView(final ViewGroup parent) { - final View orphanedView = mInterfaceState.findFirstOrphanedView(); - if (null != orphanedView) return orphanedView; // Will be sent to onBindView - final View newView = super.onCreateView(parent); - return mInterfaceState.addToCacheAndReturnView(newView); - } - - public boolean hasPriorityOver(final int otherPrefStatus) { - // Both of these should be one of MetadataDbHelper.STATUS_* - return mStatus > otherPrefStatus; - } - - private String getSummary(final int status) { - final Context context = getContext(); - switch (status) { - // If we are deleting the word list, for the user it's like it's already deleted. - // It should be reinstallable. Exposing to the user the whole complexity of - // the delayed deletion process between the dictionary pack and Android Keyboard - // would only be confusing. - case MetadataDbHelper.STATUS_DELETING: - case MetadataDbHelper.STATUS_AVAILABLE: - return context.getString(R.string.dictionary_available); - case MetadataDbHelper.STATUS_DOWNLOADING: - return context.getString(R.string.dictionary_downloading); - case MetadataDbHelper.STATUS_INSTALLED: - return context.getString(R.string.dictionary_installed); - case MetadataDbHelper.STATUS_DISABLED: - return context.getString(R.string.dictionary_disabled); - default: - return NO_STATUS_MESSAGE; - } - } - - // The table below needs to be kept in sync with MetadataDbHelper.STATUS_* since it uses - // the values as indices. - private static final int sStatusActionList[][] = { - // MetadataDbHelper.STATUS_UNKNOWN - {}, - // MetadataDbHelper.STATUS_AVAILABLE - { ButtonSwitcher.STATUS_INSTALL, ACTION_ENABLE_DICT }, - // MetadataDbHelper.STATUS_DOWNLOADING - { ButtonSwitcher.STATUS_CANCEL, ACTION_DISABLE_DICT }, - // MetadataDbHelper.STATUS_INSTALLED - { ButtonSwitcher.STATUS_DELETE, ACTION_DELETE_DICT }, - // MetadataDbHelper.STATUS_DISABLED - { ButtonSwitcher.STATUS_DELETE, ACTION_DELETE_DICT }, - // MetadataDbHelper.STATUS_DELETING - // We show 'install' because the file is supposed to be deleted. - // The user may reinstall it. - { ButtonSwitcher.STATUS_INSTALL, ACTION_ENABLE_DICT } - }; - - static int getButtonSwitcherStatus(final int status) { - if (status >= sStatusActionList.length) { - Log.e(TAG, "Unknown status " + status); - return ButtonSwitcher.STATUS_NO_BUTTON; - } - return sStatusActionList[status][0]; - } - - static int getActionIdFromStatusAndMenuEntry(final int status) { - if (status >= sStatusActionList.length) { - Log.e(TAG, "Unknown status " + status); - return ACTION_UNKNOWN; - } - return sStatusActionList[status][1]; - } - - private void disableDict() { - final Context context = getContext(); - final SharedPreferences prefs = CommonPreferences.getCommonPreferences(context); - CommonPreferences.disable(prefs, mWordlistId); - if (MetadataDbHelper.STATUS_DOWNLOADING == mStatus) { - setStatus(MetadataDbHelper.STATUS_AVAILABLE); - } else if (MetadataDbHelper.STATUS_INSTALLED == mStatus) { - // Interface-wise, we should no longer be able to come here. However, this is still - // the right thing to do if we do come here. - setStatus(MetadataDbHelper.STATUS_DISABLED); - } else { - Log.e(TAG, "Unexpected state of the word list for disabling " + mStatus); - } - } - - private void enableDict() { - final Context context = getContext(); - final SharedPreferences prefs = CommonPreferences.getCommonPreferences(context); - CommonPreferences.enable(prefs, mWordlistId); - 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() { - final Context context = getContext(); - final SharedPreferences prefs = CommonPreferences.getCommonPreferences(context); - CommonPreferences.disable(prefs, mWordlistId); - setStatus(MetadataDbHelper.STATUS_DELETING); - } - - @Override - protected void onBindView(final View view) { - super.onBindView(view); - ((ViewGroup)view).setLayoutTransition(null); - - final ButtonSwitcher buttonSwitcher = (ButtonSwitcher)view.findViewById( - R.id.wordlist_button_switcher); - // We need to clear the state of the button switcher, because we reuse views; if we didn't - // reset it would animate from whatever its old state was. - buttonSwitcher.reset(mInterfaceState); - if (mInterfaceState.isOpen(mWordlistId)) { - // The button is open. - final int previousStatus = mInterfaceState.getStatus(mWordlistId); - buttonSwitcher.setStatusAndUpdateVisuals(getButtonSwitcherStatus(previousStatus)); - if (previousStatus != mStatus) { - // We come here if the status has changed since last time. We need to animate - // the transition. - buttonSwitcher.setStatusAndUpdateVisuals(getButtonSwitcherStatus(mStatus)); - mInterfaceState.setOpen(mWordlistId, mStatus); - } - } else { - // The button is closed. - buttonSwitcher.setStatusAndUpdateVisuals(ButtonSwitcher.STATUS_NO_BUTTON); - } - buttonSwitcher.setInternalOnClickListener(new View.OnClickListener() { - @Override - public void onClick(final View v) { - onActionButtonClicked(); - } - }); - view.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(final View v) { - onWordListClicked(v); - } - }); - } - - void onWordListClicked(final View v) { - // Note : v is the preference view - final ViewParent parent = v.getParent(); - // Just in case something changed in the framework, test for the concrete class - if (!(parent instanceof ListView)) return; - final ListView listView = (ListView)parent; - final int indexToOpen; - // Close all first, we'll open back any item that needs to be open. - final boolean wasOpen = mInterfaceState.isOpen(mWordlistId); - mInterfaceState.closeAll(); - if (wasOpen) { - // This button being shown. Take note that we don't want to open any button in the - // loop below. - indexToOpen = -1; - } else { - // This button was not being shown. Open it, and remember the index of this - // child as the one to open in the following loop. - mInterfaceState.setOpen(mWordlistId, mStatus); - indexToOpen = listView.indexOfChild(v); - } - final int lastDisplayedIndex = - listView.getLastVisiblePosition() - listView.getFirstVisiblePosition(); - // The "lastDisplayedIndex" is actually displayed, hence the <= - for (int i = 0; i <= lastDisplayedIndex; ++i) { - final ButtonSwitcher buttonSwitcher = (ButtonSwitcher)listView.getChildAt(i) - .findViewById(R.id.wordlist_button_switcher); - if (i == indexToOpen) { - buttonSwitcher.setStatusAndUpdateVisuals(getButtonSwitcherStatus(mStatus)); - } else { - buttonSwitcher.setStatusAndUpdateVisuals(ButtonSwitcher.STATUS_NO_BUTTON); - } - } - } - - void onActionButtonClicked() { - switch (getActionIdFromStatusAndMenuEntry(mStatus)) { - case ACTION_ENABLE_DICT: - enableDict(); - break; - case ACTION_DISABLE_DICT: - disableDict(); - break; - case ACTION_DELETE_DICT: - deleteDict(); - break; - default: - Log.e(TAG, "Unknown menu item pressed"); - } - } -} diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/WordListPreference.kt b/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/WordListPreference.kt new file mode 100644 index 000000000..2757ea4f6 --- /dev/null +++ b/app/src/main/java/org/dslul/openboard/inputmethod/dictionarypack/WordListPreference.kt @@ -0,0 +1,214 @@ +/** + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy + * of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package org.dslul.openboard.inputmethod.dictionarypack + +import android.content.Context +import android.preference.Preference +import android.util.Log +import android.view.View +import android.view.ViewGroup +import android.widget.ListView +import org.dslul.openboard.inputmethod.latin.R +import java.util.* + +/** + * 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. + */ +class WordListPreference(context: Context?, + private val mInterfaceState: DictionaryListInterfaceState, // The id of the client for which this preference is. + private val mClientId: String?, + // Members +// The metadata word list id and version of this word list. + val mWordlistId: String, val mVersion: Int, val mLocale: Locale, + val mDescription: String, status: Int, // The size of the dictionary file + private val mFilesize: Int) : Preference(context, null) { + // The status + private var mStatus = 0 + + fun setStatus(status: Int) { + if (status == mStatus) return + mStatus = status + summary = getSummary(status) + } + + fun hasStatus(status: Int): Boolean { + return status == mStatus + } + + public override fun onCreateView(parent: ViewGroup): View { + val orphanedView = mInterfaceState.findFirstOrphanedView() + if (null != orphanedView) return orphanedView // Will be sent to onBindView + val newView = super.onCreateView(parent) + return mInterfaceState.addToCacheAndReturnView(newView) + } + + fun hasPriorityOver(otherPrefStatus: Int): Boolean { // Both of these should be one of MetadataDbHelper.STATUS_* + return mStatus > otherPrefStatus + } + + private fun getSummary(status: Int): String { + val context = context + return when (status) { + MetadataDbHelper.Companion.STATUS_DELETING, MetadataDbHelper.Companion.STATUS_AVAILABLE -> context.getString(R.string.dictionary_available) + MetadataDbHelper.Companion.STATUS_DOWNLOADING -> context.getString(R.string.dictionary_downloading) + MetadataDbHelper.Companion.STATUS_INSTALLED -> context.getString(R.string.dictionary_installed) + MetadataDbHelper.Companion.STATUS_DISABLED -> context.getString(R.string.dictionary_disabled) + else -> NO_STATUS_MESSAGE + } + } + + private fun disableDict() { + val context = context + val prefs = CommonPreferences.getCommonPreferences(context) + CommonPreferences.disable(prefs, mWordlistId) + if (MetadataDbHelper.Companion.STATUS_DOWNLOADING == mStatus) { + setStatus(MetadataDbHelper.Companion.STATUS_AVAILABLE) + } else if (MetadataDbHelper.Companion.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.Companion.STATUS_DISABLED) + } else { + Log.e(TAG, "Unexpected state of the word list for disabling $mStatus") + } + } + + private fun enableDict() { + val context = context + val prefs = CommonPreferences.getCommonPreferences(context) + CommonPreferences.enable(prefs, mWordlistId) + if (MetadataDbHelper.Companion.STATUS_AVAILABLE == mStatus) { + setStatus(MetadataDbHelper.Companion.STATUS_DOWNLOADING) + } else if (MetadataDbHelper.Companion.STATUS_DISABLED == mStatus + || MetadataDbHelper.Companion.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.Companion.STATUS_INSTALLED) + } else { + Log.e(TAG, "Unexpected state of the word list for enabling $mStatus") + } + } + + private fun deleteDict() { + val context = context + val prefs = CommonPreferences.getCommonPreferences(context) + CommonPreferences.disable(prefs, mWordlistId) + setStatus(MetadataDbHelper.Companion.STATUS_DELETING) + } + + override fun onBindView(view: View) { + super.onBindView(view) + (view as ViewGroup).layoutTransition = null + val buttonSwitcher = view.findViewById( + R.id.wordlist_button_switcher) as ButtonSwitcher + // We need to clear the state of the button switcher, because we reuse views; if we didn't +// reset it would animate from whatever its old state was. + buttonSwitcher.reset(mInterfaceState) + if (mInterfaceState.isOpen(mWordlistId)) { // The button is open. + val previousStatus = mInterfaceState.getStatus(mWordlistId) + buttonSwitcher.setStatusAndUpdateVisuals(getButtonSwitcherStatus(previousStatus)) + if (previousStatus != mStatus) { // We come here if the status has changed since last time. We need to animate +// the transition. + buttonSwitcher.setStatusAndUpdateVisuals(getButtonSwitcherStatus(mStatus)) + mInterfaceState.setOpen(mWordlistId, mStatus) + } + } else { // The button is closed. + buttonSwitcher.setStatusAndUpdateVisuals(ButtonSwitcher.Companion.STATUS_NO_BUTTON) + } + buttonSwitcher.setInternalOnClickListener(View.OnClickListener { onActionButtonClicked() }) + view.setOnClickListener { v -> onWordListClicked(v) } + } + + fun onWordListClicked(v: View) { // Note : v is the preference view + val parent = v.parent as? ListView ?: return + // Just in case something changed in the framework, test for the concrete class + val listView = parent + val indexToOpen: Int + // Close all first, we'll open back any item that needs to be open. + val wasOpen = mInterfaceState.isOpen(mWordlistId) + mInterfaceState.closeAll() + indexToOpen = if (wasOpen) { // This button being shown. Take note that we don't want to open any button in the +// loop below. + -1 + } else { // This button was not being shown. Open it, and remember the index of this +// child as the one to open in the following loop. + mInterfaceState.setOpen(mWordlistId, mStatus) + listView.indexOfChild(v) + } + val lastDisplayedIndex = listView.lastVisiblePosition - listView.firstVisiblePosition + // The "lastDisplayedIndex" is actually displayed, hence the <= + for (i in 0..lastDisplayedIndex) { + val buttonSwitcher = listView.getChildAt(i) + .findViewById(R.id.wordlist_button_switcher) as ButtonSwitcher + if (i == indexToOpen) { + buttonSwitcher.setStatusAndUpdateVisuals(getButtonSwitcherStatus(mStatus)) + } else { + buttonSwitcher.setStatusAndUpdateVisuals(ButtonSwitcher.Companion.STATUS_NO_BUTTON) + } + } + } + + fun onActionButtonClicked() { + when (getActionIdFromStatusAndMenuEntry(mStatus)) { + ACTION_ENABLE_DICT -> enableDict() + ACTION_DISABLE_DICT -> disableDict() + ACTION_DELETE_DICT -> deleteDict() + else -> Log.e(TAG, "Unknown menu item pressed") + } + } + + companion object { + private val TAG = WordListPreference::class.java.simpleName + // What to display in the "status" field when we receive unknown data as a status from +// the content provider. Empty string sounds sensible. + private const val NO_STATUS_MESSAGE = "" + /// Actions + private const val ACTION_UNKNOWN = 0 + private const val ACTION_ENABLE_DICT = 1 + private const val ACTION_DISABLE_DICT = 2 + private const val ACTION_DELETE_DICT = 3 + // The table below needs to be kept in sync with MetadataDbHelper.STATUS_* since it uses +// the values as indices. + private val sStatusActionList = arrayOf(intArrayOf(), intArrayOf(ButtonSwitcher.Companion.STATUS_INSTALL, ACTION_ENABLE_DICT), intArrayOf(ButtonSwitcher.Companion.STATUS_CANCEL, ACTION_DISABLE_DICT), intArrayOf(ButtonSwitcher.Companion.STATUS_DELETE, ACTION_DELETE_DICT), intArrayOf(ButtonSwitcher.Companion.STATUS_DELETE, ACTION_DELETE_DICT), intArrayOf(ButtonSwitcher.Companion.STATUS_INSTALL, ACTION_ENABLE_DICT)) + + fun getButtonSwitcherStatus(status: Int): Int { + if (status >= sStatusActionList.size) { + Log.e(TAG, "Unknown status $status") + return ButtonSwitcher.Companion.STATUS_NO_BUTTON + } + return sStatusActionList[status][0] + } + + fun getActionIdFromStatusAndMenuEntry(status: Int): Int { + if (status >= sStatusActionList.size) { + Log.e(TAG, "Unknown status $status") + return ACTION_UNKNOWN + } + return sStatusActionList[status][1] + } + } + + init { + layoutResource = R.layout.dictionary_line + title = mDescription + setStatus(status) + key = mWordlistId + } +} \ No newline at end of file