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