MetadataDbHelper.java revision 3bc3bc7971f15438732933cfac0db6e766e6a3e9
1/*
2 * Copyright (C) 2011 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License"); you may not
5 * use this file except in compliance with the License. You may obtain a copy of
6 * the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13 * License for the specific language governing permissions and limitations under
14 * the License.
15 */
16
17package com.android.inputmethod.dictionarypack;
18
19import android.content.ContentValues;
20import android.content.Context;
21import android.database.Cursor;
22import android.database.sqlite.SQLiteDatabase;
23import android.database.sqlite.SQLiteException;
24import android.database.sqlite.SQLiteOpenHelper;
25import android.text.TextUtils;
26import android.util.Log;
27
28import com.android.inputmethod.latin.R;
29import com.android.inputmethod.latin.utils.DebugLogUtils;
30
31import java.io.File;
32import java.util.ArrayList;
33import java.util.LinkedList;
34import java.util.List;
35import java.util.TreeMap;
36
37/**
38 * Various helper functions for the state database
39 */
40public class MetadataDbHelper extends SQLiteOpenHelper {
41    private static final String TAG = MetadataDbHelper.class.getSimpleName();
42
43    // This was the initial release version of the database. It should never be
44    // changed going forward.
45    private static final int METADATA_DATABASE_INITIAL_VERSION = 3;
46    // This is the first released version of the database that implements CLIENTID. It is
47    // used to identify the versions for upgrades. This should never change going forward.
48    private static final int METADATA_DATABASE_VERSION_WITH_CLIENTID = 6;
49    // The current database version.
50    // This MUST be increased every time the dictionary pack metadata URL changes.
51    private static final int CURRENT_METADATA_DATABASE_VERSION = 14;
52
53    private final static long NOT_A_DOWNLOAD_ID = -1;
54
55    // The number of retries allowed when attempting to download a broken dictionary.
56    public static final int DICTIONARY_RETRY_THRESHOLD = 2;
57
58    public static final String METADATA_TABLE_NAME = "pendingUpdates";
59    static final String CLIENT_TABLE_NAME = "clients";
60    public static final String PENDINGID_COLUMN = "pendingid"; // Download Manager ID
61    public static final String TYPE_COLUMN = "type";
62    public static final String STATUS_COLUMN = "status";
63    public static final String LOCALE_COLUMN = "locale";
64    public static final String WORDLISTID_COLUMN = "id";
65    public static final String DESCRIPTION_COLUMN = "description";
66    public static final String LOCAL_FILENAME_COLUMN = "filename";
67    public static final String REMOTE_FILENAME_COLUMN = "url";
68    public static final String DATE_COLUMN = "date";
69    public static final String CHECKSUM_COLUMN = "checksum";
70    public static final String FILESIZE_COLUMN = "filesize";
71    public static final String VERSION_COLUMN = "version";
72    public static final String FORMATVERSION_COLUMN = "formatversion";
73    public static final String FLAGS_COLUMN = "flags";
74    public static final String RAW_CHECKSUM_COLUMN = "rawChecksum";
75    public static final String RETRY_COUNT_COLUMN = "remainingRetries";
76    public static final int COLUMN_COUNT = 15;
77
78    private static final String CLIENT_CLIENT_ID_COLUMN = "clientid";
79    private static final String CLIENT_METADATA_URI_COLUMN = "uri";
80    private static final String CLIENT_METADATA_ADDITIONAL_ID_COLUMN = "additionalid";
81    private static final String CLIENT_LAST_UPDATE_DATE_COLUMN = "lastupdate";
82    private static final String CLIENT_PENDINGID_COLUMN = "pendingid"; // Download Manager ID
83
84    public static final String METADATA_DATABASE_NAME_STEM = "pendingUpdates";
85    public static final String METADATA_UPDATE_DESCRIPTION = "metadata";
86
87    public static final String DICTIONARIES_ASSETS_PATH = "dictionaries";
88
89    // Statuses, for storing in the STATUS_COLUMN
90    // IMPORTANT: The following are used as index arrays in ../WordListPreference
91    // Do not change their values without updating the matched code.
92    // Unknown status: this should never happen.
93    public static final int STATUS_UNKNOWN = 0;
94    // Available: this word list is available, but it is not downloaded (not downloading), because
95    // it is set not to be used.
96    public static final int STATUS_AVAILABLE = 1;
97    // Downloading: this word list is being downloaded.
98    public static final int STATUS_DOWNLOADING = 2;
99    // Installed: this word list is installed and usable.
100    public static final int STATUS_INSTALLED = 3;
101    // Disabled: this word list is installed, but has been disabled by the user.
102    public static final int STATUS_DISABLED = 4;
103    // Deleting: the user marked this word list to be deleted, but it has not been yet because
104    // Latin IME is not up yet.
105    public static final int STATUS_DELETING = 5;
106    // Retry: dictionary got corrupted, so an attempt must be done to download & install it again.
107    public static final int STATUS_RETRYING = 6;
108
109    // Types, for storing in the TYPE_COLUMN
110    // This is metadata about what is available.
111    public static final int TYPE_METADATA = 1;
112    // This is a bulk file. It should replace older files.
113    public static final int TYPE_BULK = 2;
114    // This is an incremental update, expected to be small, and meaningless on its own.
115    public static final int TYPE_UPDATE = 3;
116
117    private static final String METADATA_TABLE_CREATE =
118            "CREATE TABLE " + METADATA_TABLE_NAME + " ("
119            + PENDINGID_COLUMN + " INTEGER, "
120            + TYPE_COLUMN + " INTEGER, "
121            + STATUS_COLUMN + " INTEGER, "
122            + WORDLISTID_COLUMN + " TEXT, "
123            + LOCALE_COLUMN + " TEXT, "
124            + DESCRIPTION_COLUMN + " TEXT, "
125            + LOCAL_FILENAME_COLUMN + " TEXT, "
126            + REMOTE_FILENAME_COLUMN + " TEXT, "
127            + DATE_COLUMN + " INTEGER, "
128            + CHECKSUM_COLUMN + " TEXT, "
129            + FILESIZE_COLUMN + " INTEGER, "
130            + VERSION_COLUMN + " INTEGER,"
131            + FORMATVERSION_COLUMN + " INTEGER, "
132            + FLAGS_COLUMN + " INTEGER, "
133            + RAW_CHECKSUM_COLUMN + " TEXT,"
134            + RETRY_COUNT_COLUMN + " INTEGER, "
135            + "PRIMARY KEY (" + WORDLISTID_COLUMN + "," + VERSION_COLUMN + "));";
136    private static final String METADATA_CREATE_CLIENT_TABLE =
137            "CREATE TABLE IF NOT EXISTS " + CLIENT_TABLE_NAME + " ("
138            + CLIENT_CLIENT_ID_COLUMN + " TEXT, "
139            + CLIENT_METADATA_URI_COLUMN + " TEXT, "
140            + CLIENT_METADATA_ADDITIONAL_ID_COLUMN + " TEXT, "
141            + CLIENT_LAST_UPDATE_DATE_COLUMN + " INTEGER NOT NULL DEFAULT 0, "
142            + CLIENT_PENDINGID_COLUMN + " INTEGER, "
143            + FLAGS_COLUMN + " INTEGER, "
144            + "PRIMARY KEY (" + CLIENT_CLIENT_ID_COLUMN + "));";
145
146    // List of all metadata table columns.
147    static final String[] METADATA_TABLE_COLUMNS = { PENDINGID_COLUMN, TYPE_COLUMN,
148            STATUS_COLUMN, WORDLISTID_COLUMN, LOCALE_COLUMN, DESCRIPTION_COLUMN,
149            LOCAL_FILENAME_COLUMN, REMOTE_FILENAME_COLUMN, DATE_COLUMN, CHECKSUM_COLUMN,
150            FILESIZE_COLUMN, VERSION_COLUMN, FORMATVERSION_COLUMN, FLAGS_COLUMN,
151            RAW_CHECKSUM_COLUMN, RETRY_COUNT_COLUMN };
152    // List of all client table columns.
153    static final String[] CLIENT_TABLE_COLUMNS = { CLIENT_CLIENT_ID_COLUMN,
154            CLIENT_METADATA_URI_COLUMN, CLIENT_PENDINGID_COLUMN, FLAGS_COLUMN };
155    // List of public columns returned to clients. Everything that is not in this list is
156    // private and implementation-dependent.
157    static final String[] DICTIONARIES_LIST_PUBLIC_COLUMNS = { STATUS_COLUMN, WORDLISTID_COLUMN,
158            LOCALE_COLUMN, DESCRIPTION_COLUMN, DATE_COLUMN, FILESIZE_COLUMN, VERSION_COLUMN };
159
160    // This class exhibits a singleton-like behavior by client ID, so it is getInstance'd
161    // and has a private c'tor.
162    private static TreeMap<String, MetadataDbHelper> sInstanceMap = null;
163    public static synchronized MetadataDbHelper getInstance(final Context context,
164            final String clientIdOrNull) {
165        // As a backward compatibility feature, null can be passed here to retrieve the "default"
166        // database. Before multi-client support, the dictionary packed used only one database
167        // and would not be able to handle several dictionary sets. Passing null here retrieves
168        // this legacy database. New clients should make sure to always pass a client ID so as
169        // to avoid conflicts.
170        final String clientId = null != clientIdOrNull ? clientIdOrNull : "";
171        if (null == sInstanceMap) sInstanceMap = new TreeMap<>();
172        MetadataDbHelper helper = sInstanceMap.get(clientId);
173        if (null == helper) {
174            helper = new MetadataDbHelper(context, clientId);
175            sInstanceMap.put(clientId, helper);
176        }
177        return helper;
178    }
179    private MetadataDbHelper(final Context context, final String clientId) {
180        super(context,
181                METADATA_DATABASE_NAME_STEM + (TextUtils.isEmpty(clientId) ? "" : "." + clientId),
182                null, CURRENT_METADATA_DATABASE_VERSION);
183        mContext = context;
184        mClientId = clientId;
185    }
186
187    private final Context mContext;
188    private final String mClientId;
189
190    /**
191     * Get the database itself. This always returns the same object for any client ID. If the
192     * client ID is null, a default database is returned for backward compatibility. Don't
193     * pass null for new calls.
194     *
195     * @param context the context to create the database from. This is ignored after the first call.
196     * @param clientId the client id to retrieve the database of. null for default (deprecated)
197     * @return the database.
198     */
199    public static SQLiteDatabase getDb(final Context context, final String clientId) {
200        return getInstance(context, clientId).getWritableDatabase();
201    }
202
203    private void createClientTable(final SQLiteDatabase db) {
204        // The clients table only exists in the primary db, the one that has an empty client id
205        if (!TextUtils.isEmpty(mClientId)) return;
206        db.execSQL(METADATA_CREATE_CLIENT_TABLE);
207        final String defaultMetadataUri = mContext.getString(R.string.default_metadata_uri);
208        if (!TextUtils.isEmpty(defaultMetadataUri)) {
209            final ContentValues defaultMetadataValues = new ContentValues();
210            defaultMetadataValues.put(CLIENT_CLIENT_ID_COLUMN, "");
211            defaultMetadataValues.put(CLIENT_METADATA_URI_COLUMN, defaultMetadataUri);
212            defaultMetadataValues.put(CLIENT_PENDINGID_COLUMN, UpdateHandler.NOT_AN_ID);
213            db.insert(CLIENT_TABLE_NAME, null, defaultMetadataValues);
214        }
215    }
216
217    /**
218     * Create the table and populate it with the resources found inside the apk.
219     *
220     * @see SQLiteOpenHelper#onCreate(SQLiteDatabase)
221     *
222     * @param db the database to create and populate.
223     */
224    @Override
225    public void onCreate(final SQLiteDatabase db) {
226        db.execSQL(METADATA_TABLE_CREATE);
227        createClientTable(db);
228    }
229
230    private static void addRawChecksumColumnUnlessPresent(final SQLiteDatabase db) {
231        try {
232            db.execSQL("SELECT " + RAW_CHECKSUM_COLUMN + " FROM "
233                    + METADATA_TABLE_NAME + " LIMIT 0;");
234        } catch (SQLiteException e) {
235            Log.i(TAG, "No " + RAW_CHECKSUM_COLUMN + " column : creating it");
236            db.execSQL("ALTER TABLE " + METADATA_TABLE_NAME + " ADD COLUMN "
237                    + RAW_CHECKSUM_COLUMN + " TEXT;");
238        }
239    }
240
241    private static void addRetryCountColumnUnlessPresent(final SQLiteDatabase db) {
242        try {
243            db.execSQL("SELECT " + RETRY_COUNT_COLUMN + " FROM "
244                    + METADATA_TABLE_NAME + " LIMIT 0;");
245        } catch (SQLiteException e) {
246            Log.i(TAG, "No " + RETRY_COUNT_COLUMN + " column : creating it");
247            db.execSQL("ALTER TABLE " + METADATA_TABLE_NAME + " ADD COLUMN "
248                    + RETRY_COUNT_COLUMN + " INTEGER DEFAULT " + DICTIONARY_RETRY_THRESHOLD + ";");
249        }
250    }
251
252    /**
253     * Upgrade the database. Upgrade from version 3 is supported.
254     * Version 3 has a DB named METADATA_DATABASE_NAME_STEM containing a table METADATA_TABLE_NAME.
255     * Version 6 and above has a DB named METADATA_DATABASE_NAME_STEM containing a
256     * table CLIENT_TABLE_NAME, and for each client a table called METADATA_TABLE_STEM + "." + the
257     * name of the client and contains a table METADATA_TABLE_NAME.
258     * For schemas, see the above create statements. The schemas have never changed so far.
259     *
260     * This method is called by the framework. See {@link SQLiteOpenHelper#onUpgrade}
261     * @param db The database we are upgrading
262     * @param oldVersion The old database version (the one on the disk)
263     * @param newVersion The new database version as supplied to the constructor of SQLiteOpenHelper
264     */
265    @Override
266    public void onUpgrade(final SQLiteDatabase db, final int oldVersion, final int newVersion) {
267        // Allow automatic download of dictionaries on upgrading the database.
268        CommonPreferences.setForceDownloadDict(mContext, true);
269        if (METADATA_DATABASE_INITIAL_VERSION == oldVersion
270                && METADATA_DATABASE_VERSION_WITH_CLIENTID <= newVersion
271                && CURRENT_METADATA_DATABASE_VERSION >= newVersion) {
272            // Upgrade from version METADATA_DATABASE_INITIAL_VERSION to version
273            // METADATA_DATABASE_VERSION_WITH_CLIENT_ID
274            // Only the default database should contain the client table, so we test for mClientId.
275            if (TextUtils.isEmpty(mClientId)) {
276                // Anyway in version 3 only the default table existed so the emptiness
277                // test should always be true, but better check to be sure.
278                createClientTable(db);
279            }
280        } else if (METADATA_DATABASE_VERSION_WITH_CLIENTID < newVersion
281                && CURRENT_METADATA_DATABASE_VERSION >= newVersion) {
282            // Here we drop the client table, so that all clients send us their information again.
283            // The client table contains the URL to hit to update the available dictionaries list,
284            // but the info about the dictionaries themselves is stored in the table called
285            // METADATA_TABLE_NAME and we want to keep it, so we only drop the client table.
286            db.execSQL("DROP TABLE IF EXISTS " + CLIENT_TABLE_NAME);
287            // Only the default database should contain the client table, so we test for mClientId.
288            if (TextUtils.isEmpty(mClientId)) {
289                createClientTable(db);
290            }
291        } else {
292            // If we're not in the above case, either we are upgrading from an earlier versionCode
293            // and we should wipe the database, or we are handling a version we never heard about
294            // (can only be a bug) so it's safer to wipe the database.
295            db.execSQL("DROP TABLE IF EXISTS " + METADATA_TABLE_NAME);
296            db.execSQL("DROP TABLE IF EXISTS " + CLIENT_TABLE_NAME);
297            onCreate(db);
298        }
299        // A rawChecksum column that did not exist in the previous versions was added that
300        // corresponds to the md5 checksum of the file after decompression/decryption. This is to
301        // strengthen the system against corrupted dictionary files.
302        // The most secure way to upgrade a database is to just test for the column presence, and
303        // add it if it's not there.
304        addRawChecksumColumnUnlessPresent(db);
305
306        // A retry count column that did not exist in the previous versions was added that
307        // corresponds to the number of download & installation attempts that have been made
308        // in order to strengthen the system recovery from corrupted dictionary files.
309        // The most secure way to upgrade a database is to just test for the column presence, and
310        // add it if it's not there.
311        addRetryCountColumnUnlessPresent(db);
312    }
313
314    /**
315     * Downgrade the database. This drops and recreates the table in all cases.
316     */
317    @Override
318    public void onDowngrade(final SQLiteDatabase db, final int oldVersion, final int newVersion) {
319        // No matter what the numerical values of oldVersion and newVersion are, we know this
320        // is a downgrade (newVersion < oldVersion). There is no way to know what the future
321        // databases will look like, but we know it's extremely likely that it's okay to just
322        // drop the tables and start from scratch. Hence, we ignore the versions and just wipe
323        // everything we want to use.
324        if (oldVersion <= newVersion) {
325            Log.e(TAG, "onDowngrade database but new version is higher? " + oldVersion + " <= "
326                    + newVersion);
327        }
328        db.execSQL("DROP TABLE IF EXISTS " + METADATA_TABLE_NAME);
329        db.execSQL("DROP TABLE IF EXISTS " + CLIENT_TABLE_NAME);
330        onCreate(db);
331    }
332
333    /**
334     * Given a client ID, returns whether this client exists.
335     *
336     * @param context a context to open the database
337     * @param clientId the client ID to check
338     * @return true if the client is known, false otherwise
339     */
340    public static boolean isClientKnown(final Context context, final String clientId) {
341        // If the client is known, they'll have a non-null metadata URI. An empty string is
342        // allowed as a metadata URI, if the client doesn't want any updates to happen.
343        return null != getMetadataUriAsString(context, clientId);
344    }
345
346    /**
347     * Returns the metadata URI as a string.
348     *
349     * If the client is not known, this will return null. If it is known, it will return
350     * the URI as a string. Note that the empty string is a valid value.
351     *
352     * @param context a context instance to open the database on
353     * @param clientId the ID of the client we want the metadata URI of
354     * @return the string representation of the URI
355     */
356    public static String getMetadataUriAsString(final Context context, final String clientId) {
357        SQLiteDatabase defaultDb = MetadataDbHelper.getDb(context, null);
358        final Cursor cursor = defaultDb.query(MetadataDbHelper.CLIENT_TABLE_NAME,
359                new String[] { MetadataDbHelper.CLIENT_METADATA_URI_COLUMN,
360                        MetadataDbHelper.CLIENT_METADATA_ADDITIONAL_ID_COLUMN },
361                MetadataDbHelper.CLIENT_CLIENT_ID_COLUMN + " = ?", new String[] { clientId },
362                null, null, null, null);
363        try {
364            if (!cursor.moveToFirst()) return null;
365            return MetadataUriGetter.getUri(context, cursor.getString(0), cursor.getString(1));
366        } finally {
367            cursor.close();
368        }
369    }
370
371    /**
372     * Update the last metadata update time for all clients using a particular URI.
373     *
374     * This method searches for all clients using a particular URI and updates the last
375     * update time for this client.
376     * The current time is used as the latest update time. This saved date will be what
377     * is returned henceforth by {@link #getLastUpdateDateForClient(Context, String)},
378     * until this method is called again.
379     *
380     * @param context a context instance to open the database on
381     * @param uri the metadata URI we just downloaded
382     */
383    public static void saveLastUpdateTimeOfUri(final Context context, final String uri) {
384        PrivateLog.log("Save last update time of URI : " + uri + " " + System.currentTimeMillis());
385        final ContentValues values = new ContentValues();
386        values.put(CLIENT_LAST_UPDATE_DATE_COLUMN, System.currentTimeMillis());
387        final SQLiteDatabase defaultDb = getDb(context, null);
388        final Cursor cursor = MetadataDbHelper.queryClientIds(context);
389        if (null == cursor) return;
390        try {
391            if (!cursor.moveToFirst()) return;
392            do {
393                final String clientId = cursor.getString(0);
394                final String metadataUri =
395                        MetadataDbHelper.getMetadataUriAsString(context, clientId);
396                if (metadataUri.equals(uri)) {
397                    defaultDb.update(CLIENT_TABLE_NAME, values,
398                            CLIENT_CLIENT_ID_COLUMN + " = ?", new String[] { clientId });
399                }
400            } while (cursor.moveToNext());
401        } finally {
402            cursor.close();
403        }
404    }
405
406    /**
407     * Retrieves the last date at which we updated the metadata for this client.
408     *
409     * The returned date is in milliseconds from the EPOCH; this is the same unit as
410     * returned by {@link System#currentTimeMillis()}.
411     *
412     * @param context a context instance to open the database on
413     * @param clientId the client ID to get the latest update date of
414     * @return the last date at which this client was updated, as a long.
415     */
416    public static long getLastUpdateDateForClient(final Context context, final String clientId) {
417        SQLiteDatabase defaultDb = getDb(context, null);
418        final Cursor cursor = defaultDb.query(CLIENT_TABLE_NAME,
419                new String[] { CLIENT_LAST_UPDATE_DATE_COLUMN },
420                CLIENT_CLIENT_ID_COLUMN + " = ?",
421                new String[] { null == clientId ? "" : clientId },
422                null, null, null, null);
423        try {
424            if (!cursor.moveToFirst()) return 0;
425            return cursor.getLong(0); // Only one column, return it
426        } finally {
427            cursor.close();
428        }
429    }
430
431    /**
432     * Get the metadata download ID for a metadata URI.
433     *
434     * This will retrieve the download ID for the metadata file that has the passed URI.
435     * If this URI is not being downloaded right now, it will return NOT_AN_ID.
436     *
437     * @param context a context instance to open the database on
438     * @param uri the URI to retrieve the metadata download ID of
439     * @return the download id and start date, or null if the URL is not known
440     */
441    public static DownloadIdAndStartDate getMetadataDownloadIdAndStartDateForURI(
442            final Context context, final String uri) {
443        SQLiteDatabase defaultDb = getDb(context, null);
444        final Cursor cursor = defaultDb.query(CLIENT_TABLE_NAME,
445                new String[] { CLIENT_PENDINGID_COLUMN, CLIENT_LAST_UPDATE_DATE_COLUMN },
446                CLIENT_METADATA_URI_COLUMN + " = ?", new String[] { uri },
447                null, null, null, null);
448        try {
449            if (!cursor.moveToFirst()) return null;
450            return new DownloadIdAndStartDate(cursor.getInt(0), cursor.getLong(1));
451        } finally {
452            cursor.close();
453        }
454    }
455
456    public static long getOldestUpdateTime(final Context context) {
457        SQLiteDatabase defaultDb = getDb(context, null);
458        final Cursor cursor = defaultDb.query(CLIENT_TABLE_NAME,
459                new String[] { CLIENT_LAST_UPDATE_DATE_COLUMN },
460                null, null, null, null, null);
461        try {
462            if (!cursor.moveToFirst()) return 0;
463            final int columnIndex = 0; // Only one column queried
464            // Initialize the earliestTime to the largest possible value.
465            long earliestTime = Long.MAX_VALUE; // Almost 300 million years in the future
466            do {
467                final long thisTime = cursor.getLong(columnIndex);
468                earliestTime = Math.min(thisTime, earliestTime);
469            } while (cursor.moveToNext());
470            return earliestTime;
471        } finally {
472            cursor.close();
473        }
474    }
475
476    /**
477     * Helper method to make content values to write into the database.
478     * @return content values with all the arguments put with the right column names.
479     */
480    public static ContentValues makeContentValues(final int pendingId, final int type,
481            final int status, final String wordlistId, final String locale,
482            final String description, final String filename, final String url, final long date,
483            final String rawChecksum, final String checksum, final int retryCount,
484            final long filesize, final int version, final int formatVersion) {
485        final ContentValues result = new ContentValues(COLUMN_COUNT);
486        result.put(PENDINGID_COLUMN, pendingId);
487        result.put(TYPE_COLUMN, type);
488        result.put(WORDLISTID_COLUMN, wordlistId);
489        result.put(STATUS_COLUMN, status);
490        result.put(LOCALE_COLUMN, locale);
491        result.put(DESCRIPTION_COLUMN, description);
492        result.put(LOCAL_FILENAME_COLUMN, filename);
493        result.put(REMOTE_FILENAME_COLUMN, url);
494        result.put(DATE_COLUMN, date);
495        result.put(RAW_CHECKSUM_COLUMN, rawChecksum);
496        result.put(RETRY_COUNT_COLUMN, retryCount);
497        result.put(CHECKSUM_COLUMN, checksum);
498        result.put(FILESIZE_COLUMN, filesize);
499        result.put(VERSION_COLUMN, version);
500        result.put(FORMATVERSION_COLUMN, formatVersion);
501        result.put(FLAGS_COLUMN, 0);
502        return result;
503    }
504
505    /**
506     * Helper method to fill in an incomplete ContentValues with default values.
507     * A wordlist ID and a locale are required, otherwise BadFormatException is thrown.
508     * @return the same object that was passed in, completed with default values.
509     */
510    public static ContentValues completeWithDefaultValues(final ContentValues result)
511            throws BadFormatException {
512        if (null == result.get(WORDLISTID_COLUMN) || null == result.get(LOCALE_COLUMN)) {
513            throw new BadFormatException();
514        }
515        // 0 for the pending id, because there is none
516        if (null == result.get(PENDINGID_COLUMN)) result.put(PENDINGID_COLUMN, 0);
517        // This is a binary blob of a dictionary
518        if (null == result.get(TYPE_COLUMN)) result.put(TYPE_COLUMN, TYPE_BULK);
519        // This word list is unknown, but it's present, else we wouldn't be here, so INSTALLED
520        if (null == result.get(STATUS_COLUMN)) result.put(STATUS_COLUMN, STATUS_INSTALLED);
521        // No description unless specified, because we can't guess it
522        if (null == result.get(DESCRIPTION_COLUMN)) result.put(DESCRIPTION_COLUMN, "");
523        // File name - this is an asset, so it works as an already deleted file.
524        //     hence, we need to supply a non-existent file name. Anything will
525        //     do as long as it returns false when tested with File#exist(), and
526        //     the empty string does not, so it's set to "_".
527        if (null == result.get(LOCAL_FILENAME_COLUMN)) result.put(LOCAL_FILENAME_COLUMN, "_");
528        // No remote file name : this can't be downloaded. Unless specified.
529        if (null == result.get(REMOTE_FILENAME_COLUMN)) result.put(REMOTE_FILENAME_COLUMN, "");
530        // 0 for the update date : 1970/1/1. Unless specified.
531        if (null == result.get(DATE_COLUMN)) result.put(DATE_COLUMN, 0);
532        // Raw checksum unknown unless specified
533        if (null == result.get(RAW_CHECKSUM_COLUMN)) result.put(RAW_CHECKSUM_COLUMN, "");
534        // Retry column 0 unless specified
535        if (null == result.get(RETRY_COUNT_COLUMN)) result.put(RETRY_COUNT_COLUMN,
536                DICTIONARY_RETRY_THRESHOLD);
537        // Checksum unknown unless specified
538        if (null == result.get(CHECKSUM_COLUMN)) result.put(CHECKSUM_COLUMN, "");
539        // No filesize unless specified
540        if (null == result.get(FILESIZE_COLUMN)) result.put(FILESIZE_COLUMN, 0);
541        // Smallest possible version unless specified
542        if (null == result.get(VERSION_COLUMN)) result.put(VERSION_COLUMN, 1);
543        // Assume current format unless specified
544        if (null == result.get(FORMATVERSION_COLUMN))
545            result.put(FORMATVERSION_COLUMN, UpdateHandler.MAXIMUM_SUPPORTED_FORMAT_VERSION);
546        // No flags unless specified
547        if (null == result.get(FLAGS_COLUMN)) result.put(FLAGS_COLUMN, 0);
548        return result;
549    }
550
551    /**
552     * Reads a column in a Cursor as a String and stores it in a ContentValues object.
553     * @param result the ContentValues object to store the result in.
554     * @param cursor the Cursor to read the column from.
555     * @param columnId the column ID to read.
556     */
557    private static void putStringResult(ContentValues result, Cursor cursor, String columnId) {
558        result.put(columnId, cursor.getString(cursor.getColumnIndex(columnId)));
559    }
560
561    /**
562     * Reads a column in a Cursor as an int and stores it in a ContentValues object.
563     * @param result the ContentValues object to store the result in.
564     * @param cursor the Cursor to read the column from.
565     * @param columnId the column ID to read.
566     */
567    private static void putIntResult(ContentValues result, Cursor cursor, String columnId) {
568        result.put(columnId, cursor.getInt(cursor.getColumnIndex(columnId)));
569    }
570
571    private static ContentValues getFirstLineAsContentValues(final Cursor cursor) {
572        final ContentValues result;
573        if (cursor.moveToFirst()) {
574            result = new ContentValues(COLUMN_COUNT);
575            putIntResult(result, cursor, PENDINGID_COLUMN);
576            putIntResult(result, cursor, TYPE_COLUMN);
577            putIntResult(result, cursor, STATUS_COLUMN);
578            putStringResult(result, cursor, WORDLISTID_COLUMN);
579            putStringResult(result, cursor, LOCALE_COLUMN);
580            putStringResult(result, cursor, DESCRIPTION_COLUMN);
581            putStringResult(result, cursor, LOCAL_FILENAME_COLUMN);
582            putStringResult(result, cursor, REMOTE_FILENAME_COLUMN);
583            putIntResult(result, cursor, DATE_COLUMN);
584            putStringResult(result, cursor, RAW_CHECKSUM_COLUMN);
585            putStringResult(result, cursor, CHECKSUM_COLUMN);
586            putIntResult(result, cursor, RETRY_COUNT_COLUMN);
587            putIntResult(result, cursor, FILESIZE_COLUMN);
588            putIntResult(result, cursor, VERSION_COLUMN);
589            putIntResult(result, cursor, FORMATVERSION_COLUMN);
590            putIntResult(result, cursor, FLAGS_COLUMN);
591            if (cursor.moveToNext()) {
592                // TODO: print the second level of the stack to the log so that we know
593                // in which code path the error happened
594                Log.e(TAG, "Several SQL results when we expected only one!");
595            }
596        } else {
597            result = null;
598        }
599        return result;
600    }
601
602    /**
603     * Gets the info about as specific download, indexed by its DownloadManager ID.
604     * @param db the database to get the information from.
605     * @param id the DownloadManager id.
606     * @return metadata about this download. This returns all columns in the database.
607     */
608    public static ContentValues getContentValuesByPendingId(final SQLiteDatabase db,
609            final long id) {
610        final Cursor cursor = db.query(METADATA_TABLE_NAME,
611                METADATA_TABLE_COLUMNS,
612                PENDINGID_COLUMN + "= ?",
613                new String[] { Long.toString(id) },
614                null, null, null);
615        if (null == cursor) {
616            return null;
617        }
618        try {
619            // There should never be more than one result. If because of some bug there are,
620            // returning only one result is the right thing to do, because we couldn't handle
621            // several anyway and we should still handle one.
622            return getFirstLineAsContentValues(cursor);
623        } finally {
624            cursor.close();
625        }
626    }
627
628    /**
629     * Gets the info about an installed OR deleting word list with a specified id.
630     *
631     * Basically, this is the word list that we want to return to Android Keyboard when
632     * it asks for a specific id.
633     *
634     * @param db the database to get the information from.
635     * @param id the word list ID.
636     * @return the metadata about this word list.
637     */
638    public static ContentValues getInstalledOrDeletingWordListContentValuesByWordListId(
639            final SQLiteDatabase db, final String id) {
640        final Cursor cursor = db.query(METADATA_TABLE_NAME,
641                METADATA_TABLE_COLUMNS,
642                WORDLISTID_COLUMN + "=? AND (" + STATUS_COLUMN + "=? OR " + STATUS_COLUMN + "=?)",
643                new String[] { id, Integer.toString(STATUS_INSTALLED),
644                        Integer.toString(STATUS_DELETING) },
645                null, null, null);
646        if (null == cursor) {
647            return null;
648        }
649        try {
650            // There should only be one result, but if there are several, we can't tell which
651            // is the best, so we just return the first one.
652            return getFirstLineAsContentValues(cursor);
653        } finally {
654            cursor.close();
655        }
656    }
657
658    /**
659     * Given a specific download ID, return records for all pending downloads across all clients.
660     *
661     * If several clients use the same metadata URL, we know to only download it once, and
662     * dispatch the update process across all relevant clients when the download ends. This means
663     * several clients may share a single download ID if they share a metadata URI.
664     * The dispatching is done in
665     * {@link UpdateHandler#downloadFinished(Context, android.content.Intent)}, which
666     * finds out about the list of relevant clients by calling this method.
667     *
668     * @param context a context instance to open the databases
669     * @param downloadId the download ID to query about
670     * @return the list of records. Never null, but may be empty.
671     */
672    public static ArrayList<DownloadRecord> getDownloadRecordsForDownloadId(final Context context,
673            final long downloadId) {
674        final SQLiteDatabase defaultDb = getDb(context, "");
675        final ArrayList<DownloadRecord> results = new ArrayList<>();
676        final Cursor cursor = defaultDb.query(CLIENT_TABLE_NAME, CLIENT_TABLE_COLUMNS,
677                null, null, null, null, null);
678        try {
679            if (!cursor.moveToFirst()) return results;
680            final int clientIdIndex = cursor.getColumnIndex(CLIENT_CLIENT_ID_COLUMN);
681            final int pendingIdColumn = cursor.getColumnIndex(CLIENT_PENDINGID_COLUMN);
682            do {
683                final long pendingId = cursor.getInt(pendingIdColumn);
684                final String clientId = cursor.getString(clientIdIndex);
685                if (pendingId == downloadId) {
686                    results.add(new DownloadRecord(clientId, null));
687                }
688                final ContentValues valuesForThisClient =
689                        getContentValuesByPendingId(getDb(context, clientId), downloadId);
690                if (null != valuesForThisClient) {
691                    results.add(new DownloadRecord(clientId, valuesForThisClient));
692                }
693            } while (cursor.moveToNext());
694        } finally {
695            cursor.close();
696        }
697        return results;
698    }
699
700    /**
701     * Gets the info about a specific word list.
702     *
703     * @param db the database to get the information from.
704     * @param id the word list ID.
705     * @param version the word list version.
706     * @return the metadata about this word list.
707     */
708    public static ContentValues getContentValuesByWordListId(final SQLiteDatabase db,
709            final String id, final int version) {
710        final Cursor cursor = db.query(METADATA_TABLE_NAME,
711                METADATA_TABLE_COLUMNS,
712                WORDLISTID_COLUMN + "= ? AND " + VERSION_COLUMN + "= ? AND "
713                        + FORMATVERSION_COLUMN + "<= ?",
714                new String[]
715                        { id,
716                          Integer.toString(version),
717                          Integer.toString(UpdateHandler.MAXIMUM_SUPPORTED_FORMAT_VERSION)
718                        },
719                null /* groupBy */,
720                null /* having */,
721                FORMATVERSION_COLUMN + " DESC"/* orderBy */);
722        if (null == cursor) {
723            return null;
724        }
725        try {
726            // This is a lookup by primary key, so there can't be more than one result.
727            return getFirstLineAsContentValues(cursor);
728        } finally {
729            cursor.close();
730        }
731    }
732
733    /**
734     * Gets the info about the latest word list with an id.
735     *
736     * @param db the database to get the information from.
737     * @param id the word list ID.
738     * @return the metadata about the word list with this id and the latest version number.
739     */
740    public static ContentValues getContentValuesOfLatestAvailableWordlistById(
741            final SQLiteDatabase db, final String id) {
742        final Cursor cursor = db.query(METADATA_TABLE_NAME,
743                METADATA_TABLE_COLUMNS,
744                WORDLISTID_COLUMN + "= ?",
745                new String[] { id }, null, null, VERSION_COLUMN + " DESC", "1");
746        if (null == cursor) {
747            return null;
748        }
749        try {
750            // Return the first result from the list of results.
751            return getFirstLineAsContentValues(cursor);
752        } finally {
753            cursor.close();
754        }
755    }
756
757    /**
758     * Gets the current metadata about INSTALLED, AVAILABLE or DELETING dictionaries.
759     *
760     * This odd method is tailored to the needs of
761     * DictionaryProvider#getDictionaryWordListsForContentUri, which needs the word list if
762     * it is:
763     * - INSTALLED: this should be returned to LatinIME if the file is still inside the dictionary
764     * pack, so that it can be copied. If the file is not there, it's been copied already and should
765     * not be returned, so getDictionaryWordListsForContentUri takes care of this.
766     * - DELETING: this should be returned to LatinIME so that it can actually delete the file.
767     * - AVAILABLE: this should not be returned, but should be checked for auto-installation.
768     *
769     * @param context the context for getting the database.
770     * @param clientId the client id for retrieving the database. null for default (deprecated)
771     * @return a cursor with metadata about usable dictionaries.
772     */
773    public static Cursor queryInstalledOrDeletingOrAvailableDictionaryMetadata(
774            final Context context, final String clientId) {
775        // If clientId is null, we get the defaut DB (see #getInstance() for more about this)
776        final Cursor results = getDb(context, clientId).query(METADATA_TABLE_NAME,
777                METADATA_TABLE_COLUMNS,
778                STATUS_COLUMN + " = ? OR " + STATUS_COLUMN + " = ? OR " + STATUS_COLUMN + " = ?",
779                new String[] { Integer.toString(STATUS_INSTALLED),
780                        Integer.toString(STATUS_DELETING),
781                        Integer.toString(STATUS_AVAILABLE) },
782                null, null, LOCALE_COLUMN);
783        return results;
784    }
785
786    /**
787     * Gets the current metadata about all dictionaries.
788     *
789     * This will retrieve the metadata about all dictionaries, including
790     * older files, or files not yet downloaded.
791     *
792     * @param context the context for getting the database.
793     * @param clientId the client id for retrieving the database. null for default (deprecated)
794     * @return a cursor with metadata about usable dictionaries.
795     */
796    public static Cursor queryCurrentMetadata(final Context context, final String clientId) {
797        // If clientId is null, we get the defaut DB (see #getInstance() for more about this)
798        final Cursor results = getDb(context, clientId).query(METADATA_TABLE_NAME,
799                METADATA_TABLE_COLUMNS, null, null, null, null, LOCALE_COLUMN);
800        return results;
801    }
802
803    /**
804     * Gets the list of all dictionaries known to the dictionary provider, with only public columns.
805     *
806     * This will retrieve information about all known dictionaries, and their status. As such,
807     * it will also return information about dictionaries on the server that have not been
808     * downloaded yet, but may be requested.
809     * This only returns public columns. It does not populate internal columns in the returned
810     * cursor.
811     * The value returned by this method is intended to be good to be returned directly for a
812     * request of the list of dictionaries by a client.
813     *
814     * @param context the context to read the database from.
815     * @param clientId the client id for retrieving the database. null for default (deprecated)
816     * @return a cursor that lists all available dictionaries and their metadata.
817     */
818    public static Cursor queryDictionaries(final Context context, final String clientId) {
819        // If clientId is null, we get the defaut DB (see #getInstance() for more about this)
820        final Cursor results = getDb(context, clientId).query(METADATA_TABLE_NAME,
821                DICTIONARIES_LIST_PUBLIC_COLUMNS,
822                // Filter out empty locales so as not to return auxiliary data, like a
823                // data line for downloading metadata:
824                MetadataDbHelper.LOCALE_COLUMN + " != ?", new String[] {""},
825                // TODO: Reinstate the following code for bulk, then implement partial updates
826                /*                MetadataDbHelper.TYPE_COLUMN + " = ?",
827                new String[] { Integer.toString(MetadataDbHelper.TYPE_BULK) }, */
828                null, null, LOCALE_COLUMN);
829        return results;
830    }
831
832    /**
833     * Deletes all data associated with a client.
834     *
835     * @param context the context for opening the database
836     * @param clientId the ID of the client to delete.
837     * @return true if the client was successfully deleted, false otherwise.
838     */
839    public static boolean deleteClient(final Context context, final String clientId) {
840        // Remove all metadata associated with this client
841        final SQLiteDatabase db = getDb(context, clientId);
842        db.execSQL("DROP TABLE IF EXISTS " + METADATA_TABLE_NAME);
843        db.execSQL(METADATA_TABLE_CREATE);
844        // Remove this client's entry in the clients table
845        final SQLiteDatabase defaultDb = getDb(context, "");
846        if (0 == defaultDb.delete(CLIENT_TABLE_NAME,
847                CLIENT_CLIENT_ID_COLUMN + " = ?", new String[] { clientId })) {
848            return false;
849        }
850        return true;
851    }
852
853    /**
854     * Updates information relative to a specific client.
855     *
856     * Updatable information includes the metadata URI and the additional ID column. It may be
857     * expanded in the future.
858     * The passed values must include a client ID in the key CLIENT_CLIENT_ID_COLUMN, and it must
859     * be equal to the string passed as an argument for clientId. It may not be empty.
860     * The passed values must also include a non-null metadata URI in the
861     * CLIENT_METADATA_URI_COLUMN column, as well as a non-null additional ID in the
862     * CLIENT_METADATA_ADDITIONAL_ID_COLUMN. Both these strings may be empty.
863     * If any of the above is not complied with, this function returns without updating data.
864     *
865     * @param context the context, to open the database
866     * @param clientId the ID of the client to update
867     * @param values the values to update. Must conform to the protocol (see above)
868     */
869    public static void updateClientInfo(final Context context, final String clientId,
870            final ContentValues values) {
871        // Sanity check the content values
872        final String valuesClientId = values.getAsString(CLIENT_CLIENT_ID_COLUMN);
873        final String valuesMetadataUri = values.getAsString(CLIENT_METADATA_URI_COLUMN);
874        final String valuesMetadataAdditionalId =
875                values.getAsString(CLIENT_METADATA_ADDITIONAL_ID_COLUMN);
876        // Empty string is a valid client ID, but external apps may not configure it, so disallow
877        // both null and empty string.
878        // Empty string is a valid metadata URI if the client does not want updates, so allow
879        // empty string but disallow null.
880        // Empty string is a valid additional ID so allow empty string but disallow null.
881        if (TextUtils.isEmpty(valuesClientId) || null == valuesMetadataUri
882                || null == valuesMetadataAdditionalId) {
883            // We need all these columns to be filled in
884            DebugLogUtils.l("Missing parameter for updateClientInfo");
885            return;
886        }
887        if (!clientId.equals(valuesClientId)) {
888            // Mismatch! The client violates the protocol.
889            DebugLogUtils.l("Received an updateClientInfo request for ", clientId,
890                    " but the values " + "contain a different ID : ", valuesClientId);
891            return;
892        }
893        // Default value for a pending ID is NOT_AN_ID
894        values.put(CLIENT_PENDINGID_COLUMN, UpdateHandler.NOT_AN_ID);
895        final SQLiteDatabase defaultDb = getDb(context, "");
896        if (-1 == defaultDb.insert(CLIENT_TABLE_NAME, null, values)) {
897            defaultDb.update(CLIENT_TABLE_NAME, values,
898                    CLIENT_CLIENT_ID_COLUMN + " = ?", new String[] { clientId });
899        }
900    }
901
902    /**
903     * Retrieves the list of existing client IDs.
904     * @param context the context to open the database
905     * @return a cursor containing only one column, and one client ID per line.
906     */
907    public static Cursor queryClientIds(final Context context) {
908        return getDb(context, null).query(CLIENT_TABLE_NAME,
909                new String[] { CLIENT_CLIENT_ID_COLUMN }, null, null, null, null, null);
910    }
911
912    /**
913     * Register a download ID for a specific metadata URI.
914     *
915     * This method should be called when a download for a metadata URI is starting. It will
916     * search for all clients using this metadata URI and will register for each of them
917     * the download ID into the database for later retrieval by
918     * {@link #getDownloadRecordsForDownloadId(Context, long)}.
919     *
920     * @param context a context for opening databases
921     * @param uri the metadata URI
922     * @param downloadId the download ID
923     */
924    public static void registerMetadataDownloadId(final Context context, final String uri,
925            final long downloadId) {
926        final ContentValues values = new ContentValues();
927        values.put(CLIENT_PENDINGID_COLUMN, downloadId);
928        values.put(CLIENT_LAST_UPDATE_DATE_COLUMN, System.currentTimeMillis());
929        final SQLiteDatabase defaultDb = getDb(context, "");
930        final Cursor cursor = MetadataDbHelper.queryClientIds(context);
931        if (null == cursor) return;
932        try {
933            if (!cursor.moveToFirst()) return;
934            do {
935                final String clientId = cursor.getString(0);
936                final String metadataUri =
937                        MetadataDbHelper.getMetadataUriAsString(context, clientId);
938                if (metadataUri.equals(uri)) {
939                    defaultDb.update(CLIENT_TABLE_NAME, values,
940                            CLIENT_CLIENT_ID_COLUMN + " = ?", new String[] { clientId });
941                }
942            } while (cursor.moveToNext());
943        } finally {
944            cursor.close();
945        }
946    }
947
948    /**
949     * Marks a downloading entry as having successfully downloaded and being installed.
950     *
951     * The metadata database contains information about ongoing processes, typically ongoing
952     * downloads. This marks such an entry as having finished and having installed successfully,
953     * so it becomes INSTALLED.
954     *
955     * @param db the metadata database.
956     * @param r content values about the entry to mark as processed.
957     */
958    public static void markEntryAsFinishedDownloadingAndInstalled(final SQLiteDatabase db,
959            final ContentValues r) {
960        switch (r.getAsInteger(TYPE_COLUMN)) {
961            case TYPE_BULK:
962                DebugLogUtils.l("Ended processing a wordlist");
963                // Updating a bulk word list is a three-step operation:
964                // - Add the new entry to the table
965                // - Remove the old entry from the table
966                // - Erase the old file
967                // We start by gathering the names of the files we should delete.
968                final List<String> filenames = new LinkedList<>();
969                final Cursor c = db.query(METADATA_TABLE_NAME,
970                        new String[] { LOCAL_FILENAME_COLUMN },
971                        LOCALE_COLUMN + " = ? AND " +
972                        WORDLISTID_COLUMN + " = ? AND " + STATUS_COLUMN + " = ?",
973                        new String[] { r.getAsString(LOCALE_COLUMN),
974                                r.getAsString(WORDLISTID_COLUMN),
975                                Integer.toString(STATUS_INSTALLED) },
976                        null, null, null);
977                try {
978                    if (c.moveToFirst()) {
979                        // There should never be more than one file, but if there are, it's a bug
980                        // and we should remove them all. I think it might happen if the power of
981                        // the phone is suddenly cut during an update.
982                        final int filenameIndex = c.getColumnIndex(LOCAL_FILENAME_COLUMN);
983                        do {
984                            DebugLogUtils.l("Setting for removal", c.getString(filenameIndex));
985                            filenames.add(c.getString(filenameIndex));
986                        } while (c.moveToNext());
987                    }
988                } finally {
989                    c.close();
990                }
991                r.put(STATUS_COLUMN, STATUS_INSTALLED);
992                db.beginTransactionNonExclusive();
993                // Delete all old entries. There should never be any stalled entries, but if
994                // there are, this deletes them.
995                db.delete(METADATA_TABLE_NAME,
996                        WORDLISTID_COLUMN + " = ?",
997                        new String[] { r.getAsString(WORDLISTID_COLUMN) });
998                db.insert(METADATA_TABLE_NAME, null, r);
999                db.setTransactionSuccessful();
1000                db.endTransaction();
1001                for (String filename : filenames) {
1002                    try {
1003                        final File f = new File(filename);
1004                        f.delete();
1005                    } catch (SecurityException e) {
1006                        // No permissions to delete. Um. Can't do anything.
1007                    } // I don't think anything else can be thrown
1008                }
1009                break;
1010            default:
1011                // Unknown type: do nothing.
1012                break;
1013        }
1014     }
1015
1016    /**
1017     * Removes a downloading entry from the database.
1018     *
1019     * This is invoked when a download fails. Either we tried to download, but
1020     * we received a permanent failure and we should remove it, or we got manually
1021     * cancelled and we should leave it at that.
1022     *
1023     * @param db the metadata database.
1024     * @param id the DownloadManager id of the file.
1025     */
1026    public static void deleteDownloadingEntry(final SQLiteDatabase db, final long id) {
1027        db.delete(METADATA_TABLE_NAME, PENDINGID_COLUMN + " = ? AND " + STATUS_COLUMN + " = ?",
1028                new String[] { Long.toString(id), Integer.toString(STATUS_DOWNLOADING) });
1029    }
1030
1031    /**
1032     * Forcefully removes an entry from the database.
1033     *
1034     * This is invoked when a file is broken. The file has been downloaded, but Android
1035     * Keyboard is telling us it could not open it.
1036     *
1037     * @param db the metadata database.
1038     * @param id the id of the word list.
1039     * @param version the version of the word list.
1040     */
1041    public static void deleteEntry(final SQLiteDatabase db, final String id, final int version) {
1042        db.delete(METADATA_TABLE_NAME, WORDLISTID_COLUMN + " = ? AND " + VERSION_COLUMN + " = ?",
1043                new String[] { id, Integer.toString(version) });
1044    }
1045
1046    /**
1047     * Internal method that sets the current status of an entry of the database.
1048     *
1049     * @param db the metadata database.
1050     * @param id the id of the word list.
1051     * @param version the version of the word list.
1052     * @param status the status to set the word list to.
1053     * @param downloadId an optional download id to write, or NOT_A_DOWNLOAD_ID
1054     */
1055    private static void markEntryAs(final SQLiteDatabase db, final String id,
1056            final int version, final int status, final long downloadId) {
1057        final ContentValues values = MetadataDbHelper.getContentValuesByWordListId(db, id, version);
1058        values.put(STATUS_COLUMN, status);
1059        if (NOT_A_DOWNLOAD_ID != downloadId) {
1060            values.put(MetadataDbHelper.PENDINGID_COLUMN, downloadId);
1061        }
1062        db.update(METADATA_TABLE_NAME, values,
1063                WORDLISTID_COLUMN + " = ? AND " + VERSION_COLUMN + " = ?",
1064                new String[] { id, Integer.toString(version) });
1065    }
1066
1067    /**
1068     * Writes the status column for the wordlist with this id as enabled. Typically this
1069     * means the word list is currently disabled and we want to set its status to INSTALLED.
1070     *
1071     * @param db the metadata database.
1072     * @param id the id of the word list.
1073     * @param version the version of the word list.
1074     */
1075    public static void markEntryAsEnabled(final SQLiteDatabase db, final String id,
1076            final int version) {
1077        markEntryAs(db, id, version, STATUS_INSTALLED, NOT_A_DOWNLOAD_ID);
1078    }
1079
1080    /**
1081     * Writes the status column for the wordlist with this id as disabled. Typically this
1082     * means the word list is currently installed and we want to set its status to DISABLED.
1083     *
1084     * @param db the metadata database.
1085     * @param id the id of the word list.
1086     * @param version the version of the word list.
1087     */
1088    public static void markEntryAsDisabled(final SQLiteDatabase db, final String id,
1089            final int version) {
1090        markEntryAs(db, id, version, STATUS_DISABLED, NOT_A_DOWNLOAD_ID);
1091    }
1092
1093    /**
1094     * Writes the status column for the wordlist with this id as available. This happens for
1095     * example when a word list has been deleted but can be downloaded again.
1096     *
1097     * @param db the metadata database.
1098     * @param id the id of the word list.
1099     * @param version the version of the word list.
1100     */
1101    public static void markEntryAsAvailable(final SQLiteDatabase db, final String id,
1102            final int version) {
1103        markEntryAs(db, id, version, STATUS_AVAILABLE, NOT_A_DOWNLOAD_ID);
1104    }
1105
1106    /**
1107     * Writes the designated word list as downloadable, alongside with its download id.
1108     *
1109     * @param db the metadata database.
1110     * @param id the id of the word list.
1111     * @param version the version of the word list.
1112     * @param downloadId the download id.
1113     */
1114    public static void markEntryAsDownloading(final SQLiteDatabase db, final String id,
1115            final int version, final long downloadId) {
1116        markEntryAs(db, id, version, STATUS_DOWNLOADING, downloadId);
1117    }
1118
1119    /**
1120     * Writes the designated word list as deleting.
1121     *
1122     * @param db the metadata database.
1123     * @param id the id of the word list.
1124     * @param version the version of the word list.
1125     */
1126    public static void markEntryAsDeleting(final SQLiteDatabase db, final String id,
1127            final int version) {
1128        markEntryAs(db, id, version, STATUS_DELETING, NOT_A_DOWNLOAD_ID);
1129    }
1130
1131    /**
1132     * Checks retry counts and marks the word list as retrying if retry is possible.
1133     *
1134     * @param db the metadata database.
1135     * @param id the id of the word list.
1136     * @param version the version of the word list.
1137     * @return {@code true} if the retry is possible.
1138     */
1139    public static boolean maybeMarkEntryAsRetrying(final SQLiteDatabase db, final String id,
1140            final int version) {
1141        final ContentValues values = MetadataDbHelper.getContentValuesByWordListId(db, id, version);
1142        int retryCount = values.getAsInteger(MetadataDbHelper.RETRY_COUNT_COLUMN);
1143        if (retryCount > 1) {
1144            values.put(STATUS_COLUMN, STATUS_RETRYING);
1145            values.put(RETRY_COUNT_COLUMN, retryCount - 1);
1146            db.update(METADATA_TABLE_NAME, values,
1147                    WORDLISTID_COLUMN + " = ? AND " + VERSION_COLUMN + " = ?",
1148                    new String[] { id, Integer.toString(version) });
1149            return true;
1150        }
1151        return false;
1152    }
1153}
1154