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