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