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