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