UpdateHandler.java revision 4be6198cb73cc24e10834153c4e049644ed187e3
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.app.DownloadManager;
20import android.app.DownloadManager.Query;
21import android.app.DownloadManager.Request;
22import android.app.Notification;
23import android.app.NotificationManager;
24import android.app.PendingIntent;
25import android.content.ContentValues;
26import android.content.Context;
27import android.content.Intent;
28import android.content.SharedPreferences;
29import android.content.res.Resources;
30import android.database.Cursor;
31import android.database.sqlite.SQLiteDatabase;
32import android.net.ConnectivityManager;
33import android.net.Uri;
34import android.os.ParcelFileDescriptor;
35import android.text.TextUtils;
36import android.util.Log;
37
38import com.android.inputmethod.compat.ConnectivityManagerCompatUtils;
39import com.android.inputmethod.compat.DownloadManagerCompatUtils;
40import com.android.inputmethod.latin.R;
41import com.android.inputmethod.latin.utils.ApplicationUtils;
42import com.android.inputmethod.latin.utils.DebugLogUtils;
43
44import java.io.File;
45import java.io.FileInputStream;
46import java.io.FileNotFoundException;
47import java.io.FileOutputStream;
48import java.io.IOException;
49import java.io.InputStream;
50import java.io.InputStreamReader;
51import java.io.OutputStream;
52import java.nio.channels.FileChannel;
53import java.util.ArrayList;
54import java.util.Collections;
55import java.util.LinkedList;
56import java.util.List;
57import java.util.Locale;
58import java.util.Set;
59import java.util.TreeSet;
60
61/**
62 * Handler for the update process.
63 *
64 * This class is in charge of coordinating the update process for the various dictionaries
65 * stored in the dictionary pack.
66 */
67public final class UpdateHandler {
68    static final String TAG = "DictionaryProvider:" + UpdateHandler.class.getSimpleName();
69    private static final boolean DEBUG = DictionaryProvider.DEBUG;
70
71    // Used to prevent trying to read the id of the downloaded file before it is written
72    static final Object sSharedIdProtector = new Object();
73
74    // Value used to mean this is not a real DownloadManager downloaded file id
75    // DownloadManager uses as an ID numbers returned out of an AUTOINCREMENT column
76    // in SQLite, so it should never return anything < 0.
77    public static final int NOT_AN_ID = -1;
78    public static final int MAXIMUM_SUPPORTED_FORMAT_VERSION = 2;
79
80    // Arbitrary. Probably good if it's a power of 2, and a couple thousand bytes long.
81    private static final int FILE_COPY_BUFFER_SIZE = 8192;
82
83    // Table fixed values for metadata / downloads
84    final static String METADATA_NAME = "metadata";
85    final static int METADATA_TYPE = 0;
86    final static int WORDLIST_TYPE = 1;
87
88    // Suffix for generated dictionary files
89    private static final String DICT_FILE_SUFFIX = ".dict";
90    // Name of the category for the main dictionary
91    public static final String MAIN_DICTIONARY_CATEGORY = "main";
92
93    // The id for the "dictionary available" notification.
94    static final int DICT_AVAILABLE_NOTIFICATION_ID = 1;
95
96    /**
97     * An interface for UIs or services that want to know when something happened.
98     *
99     * This is chiefly used by the dictionary manager UI.
100     */
101    public interface UpdateEventListener {
102        public void downloadedMetadata(boolean succeeded);
103        public void wordListDownloadFinished(String wordListId, boolean succeeded);
104        public void updateCycleCompleted();
105    }
106
107    /**
108     * The list of currently registered listeners.
109     */
110    private static List<UpdateEventListener> sUpdateEventListeners
111            = Collections.synchronizedList(new LinkedList<UpdateEventListener>());
112
113    /**
114     * Register a new listener to be notified of updates.
115     *
116     * Don't forget to call unregisterUpdateEventListener when done with it, or
117     * it will leak the register.
118     */
119    public static void registerUpdateEventListener(final UpdateEventListener listener) {
120        sUpdateEventListeners.add(listener);
121    }
122
123    /**
124     * Unregister a previously registered listener.
125     */
126    public static void unregisterUpdateEventListener(final UpdateEventListener listener) {
127        sUpdateEventListeners.remove(listener);
128    }
129
130    private static final String DOWNLOAD_OVER_METERED_SETTING_PREFS_KEY = "downloadOverMetered";
131
132    /**
133     * Write the DownloadManager ID of the currently downloading metadata to permanent storage.
134     *
135     * @param context to open shared prefs
136     * @param uri the uri of the metadata
137     * @param downloadId the id returned by DownloadManager
138     */
139    private static void writeMetadataDownloadId(final Context context, final String uri,
140            final long downloadId) {
141        MetadataDbHelper.registerMetadataDownloadId(context, uri, downloadId);
142    }
143
144    public static final int DOWNLOAD_OVER_METERED_SETTING_UNKNOWN = 0;
145    public static final int DOWNLOAD_OVER_METERED_ALLOWED = 1;
146    public static final int DOWNLOAD_OVER_METERED_DISALLOWED = 2;
147
148    /**
149     * Sets the setting that tells us whether we may download over a metered connection.
150     */
151    public static void setDownloadOverMeteredSetting(final Context context,
152            final boolean shouldDownloadOverMetered) {
153        final SharedPreferences prefs = CommonPreferences.getCommonPreferences(context);
154        final SharedPreferences.Editor editor = prefs.edit();
155        editor.putInt(DOWNLOAD_OVER_METERED_SETTING_PREFS_KEY, shouldDownloadOverMetered
156                ? DOWNLOAD_OVER_METERED_ALLOWED : DOWNLOAD_OVER_METERED_DISALLOWED);
157        editor.apply();
158    }
159
160    /**
161     * Gets the setting that tells us whether we may download over a metered connection.
162     *
163     * This returns one of the constants above.
164     */
165    public static int getDownloadOverMeteredSetting(final Context context) {
166        final SharedPreferences prefs = CommonPreferences.getCommonPreferences(context);
167        final int setting = prefs.getInt(DOWNLOAD_OVER_METERED_SETTING_PREFS_KEY,
168                DOWNLOAD_OVER_METERED_SETTING_UNKNOWN);
169        return setting;
170    }
171
172    /**
173     * Download latest metadata from the server through DownloadManager for all known clients
174     * @param context The context for retrieving resources
175     * @param updateNow Whether we should update NOW, or respect bandwidth policies
176     * @return true if an update successfully started, false otherwise.
177     */
178    public static boolean tryUpdate(final Context context, final boolean updateNow) {
179        // TODO: loop through all clients instead of only doing the default one.
180        final TreeSet<String> uris = new TreeSet<String>();
181        final Cursor cursor = MetadataDbHelper.queryClientIds(context);
182        if (null == cursor) return false;
183        try {
184            if (!cursor.moveToFirst()) return false;
185            do {
186                final String clientId = cursor.getString(0);
187                final String metadataUri =
188                        MetadataDbHelper.getMetadataUriAsString(context, clientId);
189                PrivateLog.log("Update for clientId " + DebugLogUtils.s(clientId));
190                DebugLogUtils.l("Update for clientId", clientId, " which uses URI ", metadataUri);
191                uris.add(metadataUri);
192            } while (cursor.moveToNext());
193        } finally {
194            cursor.close();
195        }
196        boolean started = false;
197        for (final String metadataUri : uris) {
198            if (!TextUtils.isEmpty(metadataUri)) {
199                // If the metadata URI is empty, that means we should never update it at all.
200                // It should not be possible to come here with a null metadata URI, because
201                // it should have been rejected at the time of client registration; if there
202                // is a bug and it happens anyway, doing nothing is the right thing to do.
203                // For more information, {@see DictionaryProvider#insert(Uri, ContentValues)}.
204                updateClientsWithMetadataUri(context, updateNow, metadataUri);
205                started = true;
206            }
207        }
208        return started;
209    }
210
211    /**
212     * Download latest metadata from the server through DownloadManager for all relevant clients
213     *
214     * @param context The context for retrieving resources
215     * @param updateNow Whether we should update NOW, or respect bandwidth policies
216     * @param metadataUri The client to update
217     */
218    private static void updateClientsWithMetadataUri(final Context context,
219            final boolean updateNow, final String metadataUri) {
220        PrivateLog.log("Update for metadata URI " + DebugLogUtils.s(metadataUri));
221        // Adding a disambiguator to circumvent a bug in older versions of DownloadManager.
222        // DownloadManager also stupidly cuts the extension to replace with its own that it
223        // gets from the content-type. We need to circumvent this.
224        final String disambiguator = "#" + System.currentTimeMillis()
225                + ApplicationUtils.getVersionName(context) + ".json";
226        final Request metadataRequest = new Request(Uri.parse(metadataUri + disambiguator));
227        DebugLogUtils.l("Request =", metadataRequest);
228
229        final Resources res = context.getResources();
230        // By default, download over roaming is allowed and all network types are allowed too.
231        if (!updateNow) {
232            final boolean allowedOverMetered = res.getBoolean(R.bool.allow_over_metered);
233            // If we don't have to update NOW, then only do it over non-metered connections.
234            if (DownloadManagerCompatUtils.hasSetAllowedOverMetered()) {
235                DownloadManagerCompatUtils.setAllowedOverMetered(metadataRequest,
236                        allowedOverMetered);
237            } else if (!allowedOverMetered) {
238                metadataRequest.setAllowedNetworkTypes(Request.NETWORK_WIFI);
239            }
240            metadataRequest.setAllowedOverRoaming(res.getBoolean(R.bool.allow_over_roaming));
241        }
242        final boolean notificationVisible = updateNow
243                ? res.getBoolean(R.bool.display_notification_for_user_requested_update)
244                : res.getBoolean(R.bool.display_notification_for_auto_update);
245
246        metadataRequest.setTitle(res.getString(R.string.download_description));
247        metadataRequest.setNotificationVisibility(notificationVisible
248                ? Request.VISIBILITY_VISIBLE : Request.VISIBILITY_HIDDEN);
249        metadataRequest.setVisibleInDownloadsUi(
250                res.getBoolean(R.bool.metadata_downloads_visible_in_download_UI));
251
252        final DownloadManager manager =
253                (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE);
254        if (null == manager) {
255            // Download manager is not installed or disabled.
256            // TODO: fall back to self-managed download?
257            return;
258        }
259        cancelUpdateWithDownloadManager(context, metadataUri, manager);
260        final long downloadId;
261        synchronized (sSharedIdProtector) {
262            downloadId = manager.enqueue(metadataRequest);
263            DebugLogUtils.l("Metadata download requested with id", downloadId);
264            // If there is already a download in progress, it's been there for a while and
265            // there is probably something wrong with download manager. It's best to just
266            // overwrite the id and request it again. If the old one happens to finish
267            // anyway, we don't know about its ID any more, so the downloadFinished
268            // method will ignore it.
269            writeMetadataDownloadId(context, metadataUri, downloadId);
270        }
271        PrivateLog.log("Requested download with id " + downloadId);
272    }
273
274    /**
275     * Cancels a pending update, if there is one.
276     *
277     * If none, this is a no-op.
278     *
279     * @param context the context to open the database on
280     * @param clientId the id of the client
281     * @param manager an instance of DownloadManager
282     */
283    private static void cancelUpdateWithDownloadManager(final Context context,
284            final String clientId, final DownloadManager manager) {
285        synchronized (sSharedIdProtector) {
286            final long metadataDownloadId =
287                    MetadataDbHelper.getMetadataDownloadIdForClient(context, clientId);
288            if (NOT_AN_ID == metadataDownloadId) return;
289            manager.remove(metadataDownloadId);
290            writeMetadataDownloadId(context,
291                    MetadataDbHelper.getMetadataUriAsString(context, clientId), NOT_AN_ID);
292        }
293        // Consider a cancellation as a failure. As such, inform listeners that the download
294        // has failed.
295        for (UpdateEventListener listener : linkedCopyOfList(sUpdateEventListeners)) {
296            listener.downloadedMetadata(false);
297        }
298    }
299
300    /**
301     * Cancels a pending update, if there is one.
302     *
303     * If there is none, this is a no-op. This is a helper method that gets the
304     * download manager service.
305     *
306     * @param context the context, to get an instance of DownloadManager
307     * @param clientId the ID of the client we want to cancel the update of
308     */
309    public static void cancelUpdate(final Context context, final String clientId) {
310        final DownloadManager manager =
311                    (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE);
312        if (null != manager) cancelUpdateWithDownloadManager(context, clientId, manager);
313    }
314
315    /**
316     * Registers a download request and flags it as downloading in the metadata table.
317     *
318     * This is a helper method that exists to avoid race conditions where DownloadManager might
319     * finish downloading the file before the data is committed to the database.
320     * It registers the request with the DownloadManager service and also updates the metadata
321     * database directly within a synchronized section.
322     * This method has no intelligence about the data it commits to the database aside from the
323     * download request id, which is not known before submitting the request to the download
324     * manager. Hence, it only updates the relevant line.
325     *
326     * @param manager the download manager service to register the request with.
327     * @param request the request to register.
328     * @param db the metadata database.
329     * @param id the id of the word list.
330     * @param version the version of the word list.
331     * @return the download id returned by the download manager.
332     */
333    public static long registerDownloadRequest(final DownloadManager manager, final Request request,
334            final SQLiteDatabase db, final String id, final int version) {
335        DebugLogUtils.l("RegisterDownloadRequest for word list id : ", id, ", version ", version);
336        final long downloadId;
337        synchronized (sSharedIdProtector) {
338            downloadId = manager.enqueue(request);
339            DebugLogUtils.l("Download requested with id", downloadId);
340            MetadataDbHelper.markEntryAsDownloading(db, id, version, downloadId);
341        }
342        return downloadId;
343    }
344
345    /**
346     * Retrieve information about a specific download from DownloadManager.
347     */
348    private static CompletedDownloadInfo getCompletedDownloadInfo(final DownloadManager manager,
349            final long downloadId) {
350        final Query query = new Query().setFilterById(downloadId);
351        final Cursor cursor = manager.query(query);
352
353        if (null == cursor) {
354            return new CompletedDownloadInfo(null, downloadId, DownloadManager.STATUS_FAILED);
355        }
356        try {
357            final String uri;
358            final int status;
359            if (cursor.moveToNext()) {
360                final int columnStatus = cursor.getColumnIndex(DownloadManager.COLUMN_STATUS);
361                final int columnError = cursor.getColumnIndex(DownloadManager.COLUMN_REASON);
362                final int columnUri = cursor.getColumnIndex(DownloadManager.COLUMN_URI);
363                final int error = cursor.getInt(columnError);
364                status = cursor.getInt(columnStatus);
365                final String uriWithAnchor = cursor.getString(columnUri);
366                int anchorIndex = uriWithAnchor.indexOf('#');
367                if (anchorIndex != -1) {
368                    uri = uriWithAnchor.substring(0, anchorIndex);
369                } else {
370                    uri = uriWithAnchor;
371                }
372                if (DownloadManager.STATUS_SUCCESSFUL != status) {
373                    Log.e(TAG, "Permanent failure of download " + downloadId
374                            + " with error code: " + error);
375                }
376            } else {
377                uri = null;
378                status = DownloadManager.STATUS_FAILED;
379            }
380            return new CompletedDownloadInfo(uri, downloadId, status);
381        } finally {
382            cursor.close();
383        }
384    }
385
386    private static ArrayList<DownloadRecord> getDownloadRecordsForCompletedDownloadInfo(
387            final Context context, final CompletedDownloadInfo downloadInfo) {
388        // Get and check the ID of the file we are waiting for, compare them to downloaded ones
389        synchronized(sSharedIdProtector) {
390            final ArrayList<DownloadRecord> downloadRecords =
391                    MetadataDbHelper.getDownloadRecordsForDownloadId(context,
392                            downloadInfo.mDownloadId);
393            // If any of these is metadata, we should update the DB
394            boolean hasMetadata = false;
395            for (DownloadRecord record : downloadRecords) {
396                if (null == record.mAttributes) {
397                    hasMetadata = true;
398                    break;
399                }
400            }
401            if (hasMetadata) {
402                writeMetadataDownloadId(context, downloadInfo.mUri, NOT_AN_ID);
403                MetadataDbHelper.saveLastUpdateTimeOfUri(context, downloadInfo.mUri);
404            }
405            return downloadRecords;
406        }
407    }
408
409    /**
410     * Take appropriate action after a download finished, in success or in error.
411     *
412     * This is called by the system upon broadcast from the DownloadManager that a file
413     * has been downloaded successfully.
414     * After a simple check that this is actually the file we are waiting for, this
415     * method basically coordinates the parsing and comparison of metadata, and fires
416     * the computation of the list of actions that should be taken then executes them.
417     *
418     * @param context The context for this action.
419     * @param intent The intent from the DownloadManager containing details about the download.
420     */
421    /* package */ static void downloadFinished(final Context context, final Intent intent) {
422        // Get and check the ID of the file that was downloaded
423        final long fileId = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, NOT_AN_ID);
424        PrivateLog.log("Download finished with id " + fileId);
425        DebugLogUtils.l("DownloadFinished with id", fileId);
426        if (NOT_AN_ID == fileId) return; // Spurious wake-up: ignore
427
428        final DownloadManager manager =
429                (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE);
430        final CompletedDownloadInfo downloadInfo = getCompletedDownloadInfo(manager, fileId);
431
432        final ArrayList<DownloadRecord> recordList =
433                getDownloadRecordsForCompletedDownloadInfo(context, downloadInfo);
434        if (null == recordList) return; // It was someone else's download.
435        DebugLogUtils.l("Received result for download ", fileId);
436
437        // TODO: handle gracefully a null pointer here. This is practically impossible because
438        // we come here only when DownloadManager explicitly called us when it ended a
439        // download, so we are pretty sure it's alive. It's theoretically possible that it's
440        // disabled right inbetween the firing of the intent and the control reaching here.
441
442        for (final DownloadRecord record : recordList) {
443            // downloadSuccessful is not final because we may still have exceptions from now on
444            boolean downloadSuccessful = false;
445            try {
446                if (downloadInfo.wasSuccessful()) {
447                    downloadSuccessful = handleDownloadedFile(context, record, manager, fileId);
448                }
449            } finally {
450                if (record.isMetadata()) {
451                    publishUpdateMetadataCompleted(context, downloadSuccessful);
452                } else {
453                    final SQLiteDatabase db = MetadataDbHelper.getDb(context, record.mClientId);
454                    publishUpdateWordListCompleted(context, downloadSuccessful, fileId,
455                            db, record.mAttributes, record.mClientId);
456                }
457            }
458        }
459        // Now that we're done using it, we can remove this download from DLManager
460        manager.remove(fileId);
461    }
462
463    /**
464     * Sends a broadcast informing listeners that the dictionaries were updated.
465     *
466     * This will call all local listeners through the UpdateEventListener#downloadedMetadata
467     * callback (for example, the dictionary provider interface uses this to stop the Loading
468     * animation) and send a broadcast about the metadata having been updated. For a client of
469     * the dictionary pack like Latin IME, this means it should re-query the dictionary pack
470     * for any relevant new data.
471     *
472     * @param context the context, to send the broadcast.
473     * @param downloadSuccessful whether the download of the metadata was successful or not.
474     */
475    public static void publishUpdateMetadataCompleted(final Context context,
476            final boolean downloadSuccessful) {
477        // We need to warn all listeners of what happened. But some listeners may want to
478        // remove themselves or re-register something in response. Hence we should take a
479        // snapshot of the listener list and warn them all. This also prevents any
480        // concurrent modification problem of the static list.
481        for (UpdateEventListener listener : linkedCopyOfList(sUpdateEventListeners)) {
482            listener.downloadedMetadata(downloadSuccessful);
483        }
484        publishUpdateCycleCompletedEvent(context);
485    }
486
487    private static void publishUpdateWordListCompleted(final Context context,
488            final boolean downloadSuccessful, final long fileId,
489            final SQLiteDatabase db, final ContentValues downloadedFileRecord,
490            final String clientId) {
491        synchronized(sSharedIdProtector) {
492            if (downloadSuccessful) {
493                final ActionBatch actions = new ActionBatch();
494                actions.add(new ActionBatch.InstallAfterDownloadAction(clientId,
495                        downloadedFileRecord));
496                actions.execute(context, new LogProblemReporter(TAG));
497            } else {
498                MetadataDbHelper.deleteDownloadingEntry(db, fileId);
499            }
500        }
501        // See comment above about #linkedCopyOfLists
502        for (UpdateEventListener listener : linkedCopyOfList(sUpdateEventListeners)) {
503            listener.wordListDownloadFinished(downloadedFileRecord.getAsString(
504                            MetadataDbHelper.WORDLISTID_COLUMN), downloadSuccessful);
505        }
506        publishUpdateCycleCompletedEvent(context);
507    }
508
509    private static void publishUpdateCycleCompletedEvent(final Context context) {
510        // Even if this is not successful, we have to publish the new state.
511        PrivateLog.log("Publishing update cycle completed event");
512        DebugLogUtils.l("Publishing update cycle completed event");
513        for (UpdateEventListener listener : linkedCopyOfList(sUpdateEventListeners)) {
514            listener.updateCycleCompleted();
515        }
516        signalNewDictionaryState(context);
517    }
518
519    private static boolean handleDownloadedFile(final Context context,
520            final DownloadRecord downloadRecord, final DownloadManager manager,
521            final long fileId) {
522        try {
523            // {@link handleWordList(Context,InputStream,ContentValues)}.
524            // Handle the downloaded file according to its type
525            if (downloadRecord.isMetadata()) {
526                DebugLogUtils.l("Data D/L'd is metadata for", downloadRecord.mClientId);
527                // #handleMetadata() closes its InputStream argument
528                handleMetadata(context, new ParcelFileDescriptor.AutoCloseInputStream(
529                        manager.openDownloadedFile(fileId)), downloadRecord.mClientId);
530            } else {
531                DebugLogUtils.l("Data D/L'd is a word list");
532                final int wordListStatus = downloadRecord.mAttributes.getAsInteger(
533                        MetadataDbHelper.STATUS_COLUMN);
534                if (MetadataDbHelper.STATUS_DOWNLOADING == wordListStatus) {
535                    // #handleWordList() closes its InputStream argument
536                    handleWordList(context, new ParcelFileDescriptor.AutoCloseInputStream(
537                            manager.openDownloadedFile(fileId)), downloadRecord);
538                } else {
539                    Log.e(TAG, "Spurious download ended. Maybe a cancelled download?");
540                }
541            }
542            return true;
543        } catch (FileNotFoundException e) {
544            Log.e(TAG, "A file was downloaded but it can't be opened", e);
545        } catch (IOException e) {
546            // Can't read the file... disk damage?
547            Log.e(TAG, "Can't read a file", e);
548            // TODO: Check with UX how we should warn the user.
549        } catch (IllegalStateException e) {
550            // The format of the downloaded file is incorrect. We should maybe report upstream?
551            Log.e(TAG, "Incorrect data received", e);
552        } catch (BadFormatException e) {
553            // The format of the downloaded file is incorrect. We should maybe report upstream?
554            Log.e(TAG, "Incorrect data received", e);
555        }
556        return false;
557    }
558
559    /**
560     * Returns a copy of the specified list, with all elements copied.
561     *
562     * This returns a linked list.
563     */
564    private static <T> List<T> linkedCopyOfList(final List<T> src) {
565        // Instantiation of a parameterized type is not possible in Java, so it's not possible to
566        // return the same type of list that was passed - probably the same reason why Collections
567        // does not do it. So we need to decide statically which concrete type to return.
568        return new LinkedList<T>(src);
569    }
570
571    /**
572     * Warn Android Keyboard that the state of dictionaries changed and it should refresh its data.
573     */
574    private static void signalNewDictionaryState(final Context context) {
575        final Intent newDictBroadcast =
576                new Intent(DictionaryPackConstants.NEW_DICTIONARY_INTENT_ACTION);
577        context.sendBroadcast(newDictBroadcast);
578    }
579
580    /**
581     * Parse metadata and take appropriate action (that is, upgrade dictionaries).
582     * @param context the context to read settings.
583     * @param stream an input stream pointing to the downloaded data. May not be null.
584     *  Will be closed upon finishing.
585     * @param clientId the ID of the client to update
586     * @throws BadFormatException if the metadata is not in a known format.
587     * @throws IOException if the downloaded file can't be read from the disk
588     */
589    private static void handleMetadata(final Context context, final InputStream stream,
590            final String clientId) throws IOException, BadFormatException {
591        DebugLogUtils.l("Entering handleMetadata");
592        final List<WordListMetadata> newMetadata;
593        final InputStreamReader reader = new InputStreamReader(stream);
594        try {
595            // According to the doc InputStreamReader buffers, so no need to add a buffering layer
596            newMetadata = MetadataHandler.readMetadata(reader);
597        } finally {
598            reader.close();
599        }
600
601        DebugLogUtils.l("Downloaded metadata :", newMetadata);
602        PrivateLog.log("Downloaded metadata\n" + newMetadata);
603
604        final ActionBatch actions = computeUpgradeTo(context, clientId, newMetadata);
605        // TODO: Check with UX how we should report to the user
606        // TODO: add an action to close the database
607        actions.execute(context, new LogProblemReporter(TAG));
608    }
609
610    /**
611     * Handle a word list: put it in its right place, and update the passed content values.
612     * @param context the context for opening files.
613     * @param inputStream an input stream pointing to the downloaded data. May not be null.
614     *  Will be closed upon finishing.
615     * @param downloadRecord the content values to fill the file name in.
616     * @throws IOException if files can't be read or written.
617     * @throws BadFormatException if the md5 checksum doesn't match the metadata.
618     */
619    private static void handleWordList(final Context context,
620            final InputStream inputStream, final DownloadRecord downloadRecord)
621            throws IOException, BadFormatException {
622
623        // DownloadManager does not have the ability to put the file directly where we want
624        // it, so we had it download to a temporary place. Now we move it. It will be deleted
625        // automatically by DownloadManager.
626        DebugLogUtils.l("Downloaded a new word list :", downloadRecord.mAttributes.getAsString(
627                MetadataDbHelper.DESCRIPTION_COLUMN), "for", downloadRecord.mClientId);
628        PrivateLog.log("Downloaded a new word list with description : "
629                + downloadRecord.mAttributes.getAsString(MetadataDbHelper.DESCRIPTION_COLUMN)
630                + " for " + downloadRecord.mClientId);
631
632        final String locale =
633                downloadRecord.mAttributes.getAsString(MetadataDbHelper.LOCALE_COLUMN);
634        final String destinationFile = getTempFileName(context, locale);
635        downloadRecord.mAttributes.put(MetadataDbHelper.LOCAL_FILENAME_COLUMN, destinationFile);
636
637        FileOutputStream outputStream = null;
638        try {
639            outputStream = context.openFileOutput(destinationFile, Context.MODE_PRIVATE);
640            copyFile(inputStream, outputStream);
641        } finally {
642            inputStream.close();
643            if (outputStream != null) {
644                outputStream.close();
645            }
646        }
647
648        // TODO: Consolidate this MD5 calculation with file copying above.
649        // We need to reopen the file because the inputstream bytes have been consumed, and there
650        // is nothing in InputStream to reopen or rewind the stream
651        FileInputStream copiedFile = null;
652        final String md5sum;
653        try {
654            copiedFile = context.openFileInput(destinationFile);
655            md5sum = MD5Calculator.checksum(copiedFile);
656        } finally {
657            if (copiedFile != null) {
658                copiedFile.close();
659            }
660        }
661        if (TextUtils.isEmpty(md5sum)) {
662            return; // We can't compute the checksum anyway, so return and hope for the best
663        }
664        if (!md5sum.equals(downloadRecord.mAttributes.getAsString(
665                MetadataDbHelper.CHECKSUM_COLUMN))) {
666            context.deleteFile(destinationFile);
667            throw new BadFormatException("MD5 checksum check failed : \"" + md5sum + "\" <> \""
668                    + downloadRecord.mAttributes.getAsString(MetadataDbHelper.CHECKSUM_COLUMN)
669                    + "\"");
670        }
671    }
672
673    /**
674     * Copies in to out using FileChannels.
675     *
676     * This tries to use channels for fast copying. If it doesn't work, fall back to
677     * copyFileFallBack below.
678     *
679     * @param in the stream to copy from.
680     * @param out the stream to copy to.
681     * @throws IOException if both the normal and fallback methods raise exceptions.
682     */
683    private static void copyFile(final InputStream in, final OutputStream out)
684            throws IOException {
685        DebugLogUtils.l("Copying files");
686        if (!(in instanceof FileInputStream) || !(out instanceof FileOutputStream)) {
687            DebugLogUtils.l("Not the right types");
688            copyFileFallback(in, out);
689        } else {
690            try {
691                final FileChannel sourceChannel = ((FileInputStream) in).getChannel();
692                final FileChannel destinationChannel = ((FileOutputStream) out).getChannel();
693                sourceChannel.transferTo(0, Integer.MAX_VALUE, destinationChannel);
694            } catch (IOException e) {
695                // Can't work with channels, or something went wrong. Copy by hand.
696                DebugLogUtils.l("Won't work");
697                copyFileFallback(in, out);
698            }
699        }
700    }
701
702    /**
703     * Copies in to out with read/write methods, not FileChannels.
704     *
705     * @param in the stream to copy from.
706     * @param out the stream to copy to.
707     * @throws IOException if a read or a write fails.
708     */
709    private static void copyFileFallback(final InputStream in, final OutputStream out)
710            throws IOException {
711        DebugLogUtils.l("Falling back to slow copy");
712        final byte[] buffer = new byte[FILE_COPY_BUFFER_SIZE];
713        for (int readBytes = in.read(buffer); readBytes >= 0; readBytes = in.read(buffer))
714            out.write(buffer, 0, readBytes);
715    }
716
717    /**
718     * Creates and returns a new file to store a dictionary
719     * @param context the context to use to open the file.
720     * @param locale the locale for this dictionary, to make the file name more readable.
721     * @return the file name, or throw an exception.
722     * @throws IOException if the file cannot be created.
723     */
724    private static String getTempFileName(final Context context, final String locale)
725            throws IOException {
726        DebugLogUtils.l("Entering openTempFileOutput");
727        final File dir = context.getFilesDir();
728        final File f = File.createTempFile(locale + "___", DICT_FILE_SUFFIX, dir);
729        DebugLogUtils.l("File name is", f.getName());
730        return f.getName();
731    }
732
733    /**
734     * Compare metadata (collections of word lists).
735     *
736     * This method takes whole metadata sets directly and compares them, matching the wordlists in
737     * each of them on the id. It creates an ActionBatch object that can be .execute()'d to perform
738     * the actual upgrade from `from' to `to'.
739     *
740     * @param context the context to open databases on.
741     * @param clientId the id of the client.
742     * @param from the dictionary descriptor (as a list of wordlists) to upgrade from.
743     * @param to the dictionary descriptor (as a list of wordlists) to upgrade to.
744     * @return an ordered list of runnables to be called to upgrade.
745     */
746    private static ActionBatch compareMetadataForUpgrade(final Context context,
747            final String clientId, List<WordListMetadata> from, List<WordListMetadata> to) {
748        final ActionBatch actions = new ActionBatch();
749        // Upgrade existing word lists
750        DebugLogUtils.l("Comparing dictionaries");
751        final Set<String> wordListIds = new TreeSet<String>();
752        // TODO: Can these be null?
753        if (null == from) from = new ArrayList<WordListMetadata>();
754        if (null == to) to = new ArrayList<WordListMetadata>();
755        for (WordListMetadata wlData : from) wordListIds.add(wlData.mId);
756        for (WordListMetadata wlData : to) wordListIds.add(wlData.mId);
757        for (String id : wordListIds) {
758            final WordListMetadata currentInfo = MetadataHandler.findWordListById(from, id);
759            final WordListMetadata metadataInfo = MetadataHandler.findWordListById(to, id);
760            // TODO: Remove the following unnecessary check, since we are now doing the filtering
761            // inside findWordListById.
762            final WordListMetadata newInfo = null == metadataInfo
763                    || metadataInfo.mFormatVersion > MAXIMUM_SUPPORTED_FORMAT_VERSION
764                            ? null : metadataInfo;
765            DebugLogUtils.l("Considering updating ", id, "currentInfo =", currentInfo);
766
767            if (null == currentInfo && null == newInfo) {
768                // This may happen if a new word list appeared that we can't handle.
769                if (null == metadataInfo) {
770                    // What happened? Bug in Set<>?
771                    Log.e(TAG, "Got an id for a wordlist that is neither in from nor in to");
772                } else {
773                    // We may come here if there is a new word list that we can't handle.
774                    Log.i(TAG, "Can't handle word list with id '" + id + "' because it has format"
775                            + " version " + metadataInfo.mFormatVersion + " and the maximum version"
776                            + "we can handle is " + MAXIMUM_SUPPORTED_FORMAT_VERSION);
777                }
778                continue;
779            } else if (null == currentInfo) {
780                // This is the case where a new list that we did not know of popped on the server.
781                // Make it available.
782                actions.add(new ActionBatch.MakeAvailableAction(clientId, newInfo));
783            } else if (null == newInfo) {
784                // This is the case where an old list we had is not in the server data any more.
785                // Pass false to ForgetAction: this may be installed and we still want to apply
786                // a forget-like action (remove the URL) if it is, so we want to turn off the
787                // status == AVAILABLE check. If it's DELETING, this is the right thing to do,
788                // as we want to leave the record as long as Android Keyboard has not deleted it ;
789                // the record will be removed when the file is actually deleted.
790                actions.add(new ActionBatch.ForgetAction(clientId, currentInfo, false));
791            } else {
792                final SQLiteDatabase db = MetadataDbHelper.getDb(context, clientId);
793                if (newInfo.mVersion == currentInfo.mVersion) {
794                    // If it's the same id/version, we update the DB with the new values.
795                    // It doesn't matter too much if they didn't change.
796                    actions.add(new ActionBatch.UpdateDataAction(clientId, newInfo));
797                } else if (newInfo.mVersion > currentInfo.mVersion) {
798                    // If it's a new version, it's a different entry in the database. Make it
799                    // available, and if it's installed, also start the download.
800                    final ContentValues values = MetadataDbHelper.getContentValuesByWordListId(db,
801                            currentInfo.mId, currentInfo.mVersion);
802                    final int status = values.getAsInteger(MetadataDbHelper.STATUS_COLUMN);
803                    actions.add(new ActionBatch.MakeAvailableAction(clientId, newInfo));
804                    if (status == MetadataDbHelper.STATUS_INSTALLED
805                            || status == MetadataDbHelper.STATUS_DISABLED) {
806                        actions.add(new ActionBatch.StartDownloadAction(clientId, newInfo, false));
807                    } else {
808                        // Pass true to ForgetAction: this is indeed an update to a non-installed
809                        // word list, so activate status == AVAILABLE check
810                        // In case the status is DELETING, this is the right thing to do. It will
811                        // leave the entry as DELETING and remove its URL so that Android Keyboard
812                        // can delete it the next time it starts up.
813                        actions.add(new ActionBatch.ForgetAction(clientId, currentInfo, true));
814                    }
815                } else if (DEBUG) {
816                    Log.i(TAG, "Not updating word list " + id
817                            + " : current list timestamp is " + currentInfo.mLastUpdate
818                                    + " ; new list timestamp is " + newInfo.mLastUpdate);
819                }
820            }
821        }
822        return actions;
823    }
824
825    /**
826     * Computes an upgrade from the current state of the dictionaries to some desired state.
827     * @param context the context for reading settings and files.
828     * @param clientId the id of the client.
829     * @param newMetadata the state we want to upgrade to.
830     * @return the upgrade from the current state to the desired state, ready to be executed.
831     */
832    public static ActionBatch computeUpgradeTo(final Context context, final String clientId,
833            final List<WordListMetadata> newMetadata) {
834        final List<WordListMetadata> currentMetadata =
835                MetadataHandler.getCurrentMetadata(context, clientId);
836        return compareMetadataForUpgrade(context, clientId, currentMetadata, newMetadata);
837    }
838
839    /**
840     * Shows the notification that informs the user a dictionary is available.
841     *
842     * When this notification is clicked, the dialog for downloading the dictionary
843     * over a metered connection is shown.
844     */
845    private static void showDictionaryAvailableNotification(final Context context,
846            final String clientId, final ContentValues installCandidate) {
847        final String localeString = installCandidate.getAsString(MetadataDbHelper.LOCALE_COLUMN);
848        final Intent intent = new Intent();
849        intent.setClass(context, DownloadOverMeteredDialog.class);
850        intent.putExtra(DownloadOverMeteredDialog.CLIENT_ID_KEY, clientId);
851        intent.putExtra(DownloadOverMeteredDialog.WORDLIST_TO_DOWNLOAD_KEY,
852                installCandidate.getAsString(MetadataDbHelper.WORDLISTID_COLUMN));
853        intent.putExtra(DownloadOverMeteredDialog.SIZE_KEY,
854                installCandidate.getAsInteger(MetadataDbHelper.FILESIZE_COLUMN));
855        intent.putExtra(DownloadOverMeteredDialog.LOCALE_KEY, localeString);
856        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS);
857        final PendingIntent notificationIntent = PendingIntent.getActivity(context,
858                0 /* requestCode */, intent,
859                PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_ONE_SHOT);
860        final NotificationManager notificationManager =
861                (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
862        // None of those are expected to happen, but just in case...
863        if (null == notificationIntent || null == notificationManager) return;
864
865        final Locale locale = LocaleUtils.constructLocaleFromString(localeString);
866        final String language = (null == locale ? "" : locale.getDisplayLanguage());
867        final String titleFormat = context.getString(R.string.dict_available_notification_title);
868        final String notificationTitle = String.format(titleFormat, language);
869        final Notification notification = new Notification.Builder(context)
870                .setAutoCancel(true)
871                .setContentIntent(notificationIntent)
872                .setContentTitle(notificationTitle)
873                .setContentText(context.getString(R.string.dict_available_notification_description))
874                .setTicker(notificationTitle)
875                .setOngoing(false)
876                .setOnlyAlertOnce(true)
877                .setSmallIcon(R.drawable.ic_notify_dictionary)
878                .getNotification();
879        notificationManager.notify(DICT_AVAILABLE_NOTIFICATION_ID, notification);
880    }
881
882    /**
883     * Installs a word list if it has never been requested.
884     *
885     * This is called when a word list is requested, and is available but not installed. It checks
886     * the conditions for auto-installation: if the dictionary is a main dictionary for this
887     * language, and it has never been opted out through the dictionary interface, then we start
888     * installing it. For the user who enables a language and uses it for the first time, the
889     * dictionary should magically start being used a short time after they start typing.
890     * The mayPrompt argument indicates whether we should prompt the user for a decision to
891     * download or not, in case we decide we are in the case where we should download - this
892     * roughly happens when the current connectivity is 3G. See
893     * DictionaryProvider#getDictionaryWordListsForContentUri for details.
894     */
895    // As opposed to many other methods, this method does not need the version of the word
896    // list because it may only install the latest version we know about for this specific
897    // word list ID / client ID combination.
898    public static void installIfNeverRequested(final Context context, final String clientId,
899            final String wordlistId, final boolean mayPrompt) {
900        final String[] idArray = wordlistId.split(DictionaryProvider.ID_CATEGORY_SEPARATOR);
901        // If we have a new-format dictionary id (category:manual_id), then use the
902        // specified category. Otherwise, it is a main dictionary, so force the
903        // MAIN category upon it.
904        final String category = 2 == idArray.length ? idArray[0] : MAIN_DICTIONARY_CATEGORY;
905        if (!MAIN_DICTIONARY_CATEGORY.equals(category)) {
906            // Not a main dictionary. We only auto-install main dictionaries, so we can return now.
907            return;
908        }
909        if (CommonPreferences.getCommonPreferences(context).contains(wordlistId)) {
910            // If some kind of settings has been done in the past for this specific id, then
911            // this is not a candidate for auto-install. Because it already is either true,
912            // in which case it may be installed or downloading or whatever, and we don't
913            // need to care about it because it's already handled or being handled, or it's false
914            // in which case it means the user explicitely turned it off and don't want to have
915            // it installed. So we quit right away.
916            return;
917        }
918
919        final SQLiteDatabase db = MetadataDbHelper.getDb(context, clientId);
920        final ContentValues installCandidate =
921                MetadataDbHelper.getContentValuesOfLatestAvailableWordlistById(db, wordlistId);
922        if (MetadataDbHelper.STATUS_AVAILABLE
923                != installCandidate.getAsInteger(MetadataDbHelper.STATUS_COLUMN)) {
924            // If it's not "AVAILABLE", we want to stop now. Because candidates for auto-install
925            // are lists that we know are available, but we also know have never been installed.
926            // It does obviously not concern already installed lists, or downloading lists,
927            // or those that have been disabled, flagged as deleting... So anything else than
928            // AVAILABLE means we don't auto-install.
929            return;
930        }
931
932        if (mayPrompt
933                && DOWNLOAD_OVER_METERED_SETTING_UNKNOWN
934                        == getDownloadOverMeteredSetting(context)) {
935            final ConnectivityManager cm =
936                    (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
937            if (ConnectivityManagerCompatUtils.isActiveNetworkMetered(cm)) {
938                showDictionaryAvailableNotification(context, clientId, installCandidate);
939                return;
940            }
941        }
942
943        // We decided against prompting the user for a decision. This may be because we were
944        // explicitly asked not to, or because we are currently on wi-fi anyway, or because we
945        // already know the answer to the question. We'll enqueue a request ; StartDownloadAction
946        // knows to use the correct type of network according to the current settings.
947
948        // Also note that once it's auto-installed, a word list will be marked as INSTALLED. It will
949        // thus receive automatic updates if there are any, which is what we want. If the user does
950        // not want this word list, they will have to go to the settings and change them, which will
951        // change the shared preferences. So there is no way for a word list that has been
952        // auto-installed once to get auto-installed again, and that's what we want.
953        final ActionBatch actions = new ActionBatch();
954        actions.add(new ActionBatch.StartDownloadAction(clientId,
955                WordListMetadata.createFromContentValues(installCandidate), false));
956        final String localeString = installCandidate.getAsString(MetadataDbHelper.LOCALE_COLUMN);
957        // We are in a content provider: we can't do any UI at all. We have to defer the displaying
958        // itself to the service. Also, we only display this when the user does not have a
959        // dictionary for this language already: we know that from the mayPrompt argument.
960        if (mayPrompt) {
961            final Intent intent = new Intent();
962            intent.setClass(context, DictionaryService.class);
963            intent.setAction(DictionaryService.SHOW_DOWNLOAD_TOAST_INTENT_ACTION);
964            intent.putExtra(DictionaryService.LOCALE_INTENT_ARGUMENT, localeString);
965            context.startService(intent);
966        }
967        actions.execute(context, new LogProblemReporter(TAG));
968    }
969
970    /**
971     * Marks the word list with the passed id as used.
972     *
973     * This will download/install the list as required. The action will see that the destination
974     * word list is a valid list, and take appropriate action - in this case, mark it as used.
975     * @see ActionBatch.Action#execute
976     *
977     * @param context the context for using action batches.
978     * @param clientId the id of the client.
979     * @param wordlistId the id of the word list to mark as installed.
980     * @param version the version of the word list to mark as installed.
981     * @param status the current status of the word list.
982     * @param allowDownloadOnMeteredData whether to download even on metered data connection
983     */
984    // The version argument is not used yet, because we don't need it to retrieve the information
985    // we need. However, the pair (id, version) being the primary key to a word list in the database
986    // it feels better for consistency to pass it, and some methods retrieving information about a
987    // word list need it so we may need it in the future.
988    public static void markAsUsed(final Context context, final String clientId,
989            final String wordlistId, final int version,
990            final int status, final boolean allowDownloadOnMeteredData) {
991        final List<WordListMetadata> currentMetadata =
992                MetadataHandler.getCurrentMetadata(context, clientId);
993        WordListMetadata wordList = MetadataHandler.findWordListById(currentMetadata, wordlistId);
994        if (null == wordList) return;
995        final ActionBatch actions = new ActionBatch();
996        if (MetadataDbHelper.STATUS_DISABLED == status
997                || MetadataDbHelper.STATUS_DELETING == status) {
998            actions.add(new ActionBatch.EnableAction(clientId, wordList));
999        } else if (MetadataDbHelper.STATUS_AVAILABLE == status) {
1000            actions.add(new ActionBatch.StartDownloadAction(clientId, wordList,
1001                    allowDownloadOnMeteredData));
1002        } else {
1003            Log.e(TAG, "Unexpected state of the word list for markAsUsed : " + status);
1004        }
1005        actions.execute(context, new LogProblemReporter(TAG));
1006        signalNewDictionaryState(context);
1007    }
1008
1009    /**
1010     * Marks the word list with the passed id as unused.
1011     *
1012     * This leaves the file on the disk for ulterior use. The action will see that the destination
1013     * word list is null, and take appropriate action - in this case, mark it as unused.
1014     * @see ActionBatch.Action#execute
1015     *
1016     * @param context the context for using action batches.
1017     * @param clientId the id of the client.
1018     * @param wordlistId the id of the word list to mark as installed.
1019     * @param version the version of the word list to mark as installed.
1020     * @param status the current status of the word list.
1021     */
1022    // The version and status arguments are not used yet, but this method matches its interface to
1023    // markAsUsed for consistency.
1024    public static void markAsUnused(final Context context, final String clientId,
1025            final String wordlistId, final int version, final int status) {
1026        final List<WordListMetadata> currentMetadata =
1027                MetadataHandler.getCurrentMetadata(context, clientId);
1028        final WordListMetadata wordList =
1029                MetadataHandler.findWordListById(currentMetadata, wordlistId);
1030        if (null == wordList) return;
1031        final ActionBatch actions = new ActionBatch();
1032        actions.add(new ActionBatch.DisableAction(clientId, wordList));
1033        actions.execute(context, new LogProblemReporter(TAG));
1034        signalNewDictionaryState(context);
1035    }
1036
1037    /**
1038     * Marks the word list with the passed id as deleting.
1039     *
1040     * This basically means that on the next chance there is (right away if Android Keyboard
1041     * happens to be up, or the next time it gets up otherwise) the dictionary pack will
1042     * supply an empty dictionary to it that will replace whatever dictionary is installed.
1043     * This allows to release the space taken by a dictionary (except for the few bytes the
1044     * empty dictionary takes up), and override a built-in default dictionary so that we
1045     * can fake delete a built-in dictionary.
1046     *
1047     * @param context the context to open the database on.
1048     * @param clientId the id of the client.
1049     * @param wordlistId the id of the word list to mark as deleted.
1050     * @param version the version of the word list to mark as deleted.
1051     * @param status the current status of the word list.
1052     */
1053    public static void markAsDeleting(final Context context, final String clientId,
1054            final String wordlistId, final int version, final int status) {
1055        final List<WordListMetadata> currentMetadata =
1056                MetadataHandler.getCurrentMetadata(context, clientId);
1057        final WordListMetadata wordList =
1058                MetadataHandler.findWordListById(currentMetadata, wordlistId);
1059        if (null == wordList) return;
1060        final ActionBatch actions = new ActionBatch();
1061        actions.add(new ActionBatch.DisableAction(clientId, wordList));
1062        actions.add(new ActionBatch.StartDeleteAction(clientId, wordList));
1063        actions.execute(context, new LogProblemReporter(TAG));
1064        signalNewDictionaryState(context);
1065    }
1066
1067    /**
1068     * Marks the word list with the passed id as actually deleted.
1069     *
1070     * This reverts to available status or deletes the row as appropriate.
1071     *
1072     * @param context the context to open the database on.
1073     * @param clientId the id of the client.
1074     * @param wordlistId the id of the word list to mark as deleted.
1075     * @param version the version of the word list to mark as deleted.
1076     * @param status the current status of the word list.
1077     */
1078    public static void markAsDeleted(final Context context, final String clientId,
1079            final String wordlistId, final int version, final int status) {
1080        final List<WordListMetadata> currentMetadata =
1081                MetadataHandler.getCurrentMetadata(context, clientId);
1082        final WordListMetadata wordList =
1083                MetadataHandler.findWordListById(currentMetadata, wordlistId);
1084        if (null == wordList) return;
1085        final ActionBatch actions = new ActionBatch();
1086        actions.add(new ActionBatch.FinishDeleteAction(clientId, wordList));
1087        actions.execute(context, new LogProblemReporter(TAG));
1088        signalNewDictionaryState(context);
1089    }
1090
1091    /**
1092     * Marks the word list with the passed id as broken.
1093     *
1094     * This effectively deletes the entry from the metadata. It doesn't prevent the same
1095     * word list to be downloaded again at a later time if the same or a new version is
1096     * available the next time we download the metadata.
1097     *
1098     * @param context the context to open the database on.
1099     * @param clientId the id of the client.
1100     * @param wordlistId the id of the word list to mark as broken.
1101     * @param version the version of the word list to mark as deleted.
1102     */
1103    public static void markAsBroken(final Context context, final String clientId,
1104            final String wordlistId, final int version) {
1105        // TODO: do this on another thread to avoid blocking the UI.
1106        MetadataDbHelper.deleteEntry(MetadataDbHelper.getDb(context, clientId),
1107                wordlistId, version);
1108    }
1109}
1110