summaryrefslogtreecommitdiffstats
path: root/java/src/com/android/inputmethod/dictionarypack/ActionBatch.java
blob: ee142d845ca3425391674cbf604bcbbd3d609250 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
/*
 * Copyright (C) 2011 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License"); you may not
 * use this file except in compliance with the License. You may obtain a copy of
 * the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 * License for the specific language governing permissions and limitations under
 * the License.
 */

package com.android.inputmethod.dictionarypack;

import android.app.DownloadManager.Request;
import android.content.ContentValues;
import android.content.Context;
import android.content.res.Resources;
import android.database.sqlite.SQLiteDatabase;
import android.net.Uri;
import android.text.TextUtils;
import android.util.Log;

import com.android.inputmethod.compat.DownloadManagerCompatUtils;
import com.android.inputmethod.latin.R;
import com.android.inputmethod.latin.utils.ApplicationUtils;
import com.android.inputmethod.latin.utils.DebugLogUtils;

import java.util.LinkedList;
import java.util.Queue;

/**
 * Object representing an upgrade from one state to another.
 *
 * This implementation basically encapsulates a list of Runnable objects. In the future
 * it may manage dependencies between them. Concretely, it does not use Runnable because the
 * actions need an argument.
 */
/*

The state of a word list follows the following scheme.

       |                                   ^
  MakeAvailable                            |
       |        .------------Forget--------'
       V        |
 STATUS_AVAILABLE  <-------------------------.
       |                                     |
StartDownloadAction                  FinishDeleteAction
       |                                     |
       V                                     |
STATUS_DOWNLOADING      EnableAction-- STATUS_DELETING
       |                     |               ^
InstallAfterDownloadAction   |               |
       |     .---------------'        StartDeleteAction
       |     |                               |
       V     V                               |
 STATUS_INSTALLED  <--EnableAction--   STATUS_DISABLED
                    --DisableAction-->

  It may also be possible that DisableAction or StartDeleteAction or
  DownloadAction run when the file is still downloading.  This cancels
  the download and returns to STATUS_AVAILABLE.
  Also, an UpdateDataAction may apply in any state. It does not affect
  the state in any way (nor type, local filename, id or version) but
  may update other attributes like description or remote filename.

  Forget is an DB maintenance action that removes the entry if it is not installed or disabled.
  This happens when the word list information disappeared from the server, or when a new version
  is available and we should forget about the old one.
*/
public final class ActionBatch {
    /**
     * A piece of update.
     *
     * Action is basically like a Runnable that takes an argument.
     */
    public interface Action {
        /**
         * Execute this action NOW.
         * @param context the context to get system services, resources, databases
         */
        public void execute(final Context context);
    }

    /**
     * An action that starts downloading an available word list.
     */
    public static final class StartDownloadAction implements Action {
        static final String TAG = "DictionaryProvider:" + StartDownloadAction.class.getSimpleName();

        private final String mClientId;
        // The data to download. May not be null.
        final WordListMetadata mWordList;
        final boolean mForceStartNow;
        public StartDownloadAction(final String clientId,
                final WordListMetadata wordList, final boolean forceStartNow) {
            DebugLogUtils.l("New download action for client ", clientId, " : ", wordList);
            mClientId = clientId;
            mWordList = wordList;
            mForceStartNow = forceStartNow;
        }

        @Override
        public void execute(final Context context) {
            if (null == mWordList) { // This should never happen
                Log.e(TAG, "UpdateAction with a null parameter!");
                return;
            }
            DebugLogUtils.l("Downloading word list");
            final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId);
            final ContentValues values = MetadataDbHelper.getContentValuesByWordListId(db,
                    mWordList.mId, mWordList.mVersion);
            final int status = values.getAsInteger(MetadataDbHelper.STATUS_COLUMN);
            final DownloadManagerWrapper manager = new DownloadManagerWrapper(context);
            if (MetadataDbHelper.STATUS_DOWNLOADING == status) {
                // The word list is still downloading. Cancel the download and revert the
                // word list status to "available".
                manager.remove(values.getAsLong(MetadataDbHelper.PENDINGID_COLUMN));
                MetadataDbHelper.markEntryAsAvailable(db, mWordList.mId, mWordList.mVersion);
            } else if (MetadataDbHelper.STATUS_AVAILABLE != status
                    && MetadataDbHelper.STATUS_RETRYING != status) {
                // Should never happen
                Log.e(TAG, "Unexpected state of the word list '" + mWordList.mId + "' : " + status
                        + " for an upgrade action. Fall back to download.");
            }
            // Download it.
            DebugLogUtils.l("Upgrade word list, downloading", mWordList.mRemoteFilename);

            // This is an upgraded word list: we should download it.
            // Adding a disambiguator to circumvent a bug in older versions of DownloadManager.
            // DownloadManager also stupidly cuts the extension to replace with its own that it
            // gets from the content-type. We need to circumvent this.
            final String disambiguator = "#" + System.currentTimeMillis()
                    + ApplicationUtils.getVersionName(context) + ".dict";
            final Uri uri = Uri.parse(mWordList.mRemoteFilename + disambiguator);
            final Request request = new Request(uri);

            final Resources res = context.getResources();
            if (!mForceStartNow) {
                if (DownloadManagerCompatUtils.hasSetAllowedOverMetered()) {
                    final boolean allowOverMetered;
                    switch (UpdateHandler.getDownloadOverMeteredSetting(context)) {
                    case UpdateHandler.DOWNLOAD_OVER_METERED_DISALLOWED:
                        // User said no: don't allow.
                        allowOverMetered = false;
                        break;
                    case UpdateHandler.DOWNLOAD_OVER_METERED_ALLOWED:
                        // User said yes: allow.
                        allowOverMetered = true;
                        break;
                    default: // UpdateHandler.DOWNLOAD_OVER_METERED_SETTING_UNKNOWN
                        // Don't know: use the default value from configuration.
                        allowOverMetered = res.getBoolean(R.bool.allow_over_metered);
                    }
                    DownloadManagerCompatUtils.setAllowedOverMetered(request, allowOverMetered);
                } else {
                    request.setAllowedNetworkTypes(Request.NETWORK_WIFI);
                }
                request.setAllowedOverRoaming(res.getBoolean(R.bool.allow_over_roaming));
            } // if mForceStartNow, then allow all network types and roaming, which is the default.
            request.setTitle(mWordList.mDescription);
            request.setNotificationVisibility(
                    res.getBoolean(R.bool.display_notification_for_auto_update)
                            ? Request.VISIBILITY_VISIBLE : Request.VISIBILITY_HIDDEN);
            request.setVisibleInDownloadsUi(
                    res.getBoolean(R.bool.dict_downloads_visible_in_download_UI));

            final long downloadId = UpdateHandler.registerDownloadRequest(manager, request, db,
                    mWordList.mId, mWordList.mVersion);
            Log.i(TAG, String.format("Starting the dictionary download with version:"
                            + " %d and Url: %s", mWordList.mVersion, uri));
            DebugLogUtils.l("Starting download of", uri, "with id", downloadId);
            PrivateLog.log("Starting download of " + uri + ", id : " + downloadId);
        }
    }

    /**
     * An action that updates the database to reflect the status of a newly installed word list.
     */
    public static final class InstallAfterDownloadAction implements Action {
        static final String TAG = "DictionaryProvider:"
                + InstallAfterDownloadAction.class.getSimpleName();
        private final String mClientId;
        // The state to upgrade from. May not be null.
        final ContentValues mWordListValues;

        public InstallAfterDownloadAction(final String clientId,
                final ContentValues wordListValues) {
            DebugLogUtils.l("New InstallAfterDownloadAction for client ", clientId, " : ",
                    wordListValues);
            mClientId = clientId;
            mWordListValues = wordListValues;
        }

        @Override
        public void execute(final Context context) {
            if (null == mWordListValues) {
                Log.e(TAG, "InstallAfterDownloadAction with a null parameter!");
                return;
            }
            final int status = mWordListValues.getAsInteger(MetadataDbHelper.STATUS_COLUMN);
            if (MetadataDbHelper.STATUS_DOWNLOADING != status) {
                final String id = mWordListValues.getAsString(MetadataDbHelper.WORDLISTID_COLUMN);
                Log.e(TAG, "Unexpected state of the word list '" + id + "' : " + status
                        + " for an InstallAfterDownload action. Bailing out.");
                return;
            }
            DebugLogUtils.l("Setting word list as installed");
            final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId);
            MetadataDbHelper.markEntryAsFinishedDownloadingAndInstalled(db, mWordListValues);
        }
    }

    /**
     * An action that enables an existing word list.
     */
    public static final class EnableAction implements Action {
        static final String TAG = "DictionaryProvider:" + EnableAction.class.getSimpleName();
        private final String mClientId;
        // The state to upgrade from. May not be null.
        final WordListMetadata mWordList;

        public EnableAction(final String clientId, final WordListMetadata wordList) {
            DebugLogUtils.l("New EnableAction for client ", clientId, " : ", wordList);
            mClientId = clientId;
            mWordList = wordList;
        }

        @Override
        public void execute(final Context context) {
            if (null == mWordList) {
                Log.e(TAG, "EnableAction with a null parameter!");
                return;
            }
            DebugLogUtils.l("Enabling word list");
            final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId);
            final ContentValues values = MetadataDbHelper.getContentValuesByWordListId(db,
                    mWordList.mId, mWordList.mVersion);
            final int status = values.getAsInteger(MetadataDbHelper.STATUS_COLUMN);
            if (MetadataDbHelper.STATUS_DISABLED != status
                    && MetadataDbHelper.STATUS_DELETING != status) {
                Log.e(TAG, "Unexpected state of the word list '" + mWordList.mId + " : " + status
                      + " for an enable action. Cancelling");
                return;
            }
            MetadataDbHelper.markEntryAsEnabled(db, mWordList.mId, mWordList.mVersion);
        }
    }

    /**
     * An action that disables a word list.
     */
    public static final class DisableAction implements Action {
        static final String TAG = "DictionaryProvider:" + DisableAction.class.getSimpleName();
        private final String mClientId;
        // The word list to disable. May not be null.
        final WordListMetadata mWordList;
        public DisableAction(final String clientId, final WordListMetadata wordlist) {
            DebugLogUtils.l("New Disable action for client ", clientId, " : ", wordlist);
            mClientId = clientId;
            mWordList = wordlist;
        }

        @Override
        public void execute(final Context context) {
            if (null == mWordList) { // This should never happen
                Log.e(TAG, "DisableAction with a null word list!");
                return;
            }
            DebugLogUtils.l("Disabling word list : " + mWordList);
            final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId);
            final ContentValues values = MetadataDbHelper.getContentValuesByWordListId(db,
                    mWordList.mId, mWordList.mVersion);
            final int status = values.getAsInteger(MetadataDbHelper.STATUS_COLUMN);
            if (MetadataDbHelper.STATUS_INSTALLED == status) {
                // Disabling an installed word list
                MetadataDbHelper.markEntryAsDisabled(db, mWordList.mId, mWordList.mVersion);
            } else {
                if (MetadataDbHelper.STATUS_DOWNLOADING != status) {
                    Log.e(TAG, "Unexpected state of the word list '" + mWordList.mId + "' : "
                            + status + " for a disable action. Fall back to marking as available.");
                }
                // The word list is still downloading. Cancel the download and revert the
                // word list status to "available".
                final DownloadManagerWrapper manager = new DownloadManagerWrapper(context);
                manager.remove(values.getAsLong(MetadataDbHelper.PENDINGID_COLUMN));
                MetadataDbHelper.markEntryAsAvailable(db, mWordList.mId, mWordList.mVersion);
            }
        }
    }

    /**
     * An action that makes a word list available.
     */
    public static final class MakeAvailableAction implements Action {
        static final String TAG = "DictionaryProvider:" + MakeAvailableAction.class.getSimpleName();
        private final String mClientId;
        // The word list to make available. May not be null.
        final WordListMetadata mWordList;
        public MakeAvailableAction(final String clientId, final WordListMetadata wordlist) {
            DebugLogUtils.l("New MakeAvailable action", clientId, " : ", wordlist);
            mClientId = clientId;
            mWordList = wordlist;
        }

        @Override
        public void execute(final Context context) {
            if (null == mWordList) { // This should never happen
                Log.e(TAG, "MakeAvailableAction with a null word list!");
                return;
            }
            final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId);
            if (null != MetadataDbHelper.getContentValuesByWordListId(db,
                    mWordList.mId, mWordList.mVersion)) {
                Log.e(TAG, "Unexpected state of the word list '" + mWordList.mId + "' "
                        + " for a makeavailable action. Marking as available anyway.");
            }
            DebugLogUtils.l("Making word list available : " + mWordList);
            // If mLocalFilename is null, then it's a remote file that hasn't been downloaded
            // yet, so we set the local filename to the empty string.
            final ContentValues values = MetadataDbHelper.makeContentValues(0,
                    MetadataDbHelper.TYPE_BULK, MetadataDbHelper.STATUS_AVAILABLE,
                    mWordList.mId, mWordList.mLocale, mWordList.mDescription,
                    null == mWordList.mLocalFilename ? "" : mWordList.mLocalFilename,
                    mWordList.mRemoteFilename, mWordList.mLastUpdate, mWordList.mRawChecksum,
                    mWordList.mChecksum, mWordList.mRetryCount, mWordList.mFileSize,
                    mWordList.mVersion, mWordList.mFormatVersion);
            PrivateLog.log("Insert 'available' record for " + mWordList.mDescription
                    + " and locale " + mWordList.mLocale);
            db.insert(MetadataDbHelper.METADATA_TABLE_NAME, null, values);
        }
    }

    /**
     * An action that marks a word list as pre-installed.
     *
     * This is almost the same as MakeAvailableAction, as it only inserts a line with parameters
     * received from outside.
     * Unlike MakeAvailableAction, the parameters are not received from a downloaded metadata file
     * but from the client directly; it marks a word list as being "installed" and not "available".
     * It also explicitly sets the filename to the empty string, so that we don't try to open
     * it on our side.
     */
    public static final class MarkPreInstalledAction implements Action {
        static final String TAG = "DictionaryProvider:"
                + MarkPreInstalledAction.class.getSimpleName();
        private final String mClientId;
        // The word list to mark pre-installed. May not be null.
        final WordListMetadata mWordList;
        public MarkPreInstalledAction(final String clientId, final WordListMetadata wordlist) {
            DebugLogUtils.l("New MarkPreInstalled action", clientId, " : ", wordlist);
            mClientId = clientId;
            mWordList = wordlist;
        }

        @Override
        public void execute(final Context context) {
            if (null == mWordList) { // This should never happen
                Log.e(TAG, "MarkPreInstalledAction with a null word list!");
                return;
            }
            final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId);
            if (null != MetadataDbHelper.getContentValuesByWordListId(db,
                    mWordList.mId, mWordList.mVersion)) {
                Log.e(TAG, "Unexpected state of the word list '" + mWordList.mId + "' "
                        + " for a markpreinstalled action. Marking as preinstalled anyway.");
            }
            DebugLogUtils.l("Marking word list preinstalled : " + mWordList);
            // This word list is pre-installed : we don't have its file. We should reset
            // the local file name to the empty string so that we don't try to open it
            // accidentally. The remote filename may be set by the application if it so wishes.
            final ContentValues values = MetadataDbHelper.makeContentValues(0,
                    MetadataDbHelper.TYPE_BULK, MetadataDbHelper.STATUS_INSTALLED,
                    mWordList.mId, mWordList.mLocale, mWordList.mDescription,
                    "", mWordList.mRemoteFilename, mWordList.mLastUpdate,
                    mWordList.mRawChecksum, mWordList.mChecksum, mWordList.mRetryCount,
                    mWordList.mFileSize, mWordList.mVersion, mWordList.mFormatVersion);
            PrivateLog.log("Insert 'preinstalled' record for " + mWordList.mDescription
                    + " and locale " + mWordList.mLocale);
            db.insert(MetadataDbHelper.METADATA_TABLE_NAME, null, values);
        }
    }

    /**
     * An action that updates information about a word list - description, locale etc
     */
    public static final class UpdateDataAction implements Action {
        static final String TAG = "DictionaryProvider:" + UpdateDataAction.class.getSimpleName();
        private final String mClientId;
        final WordListMetadata mWordList;
        public UpdateDataAction(final String clientId, final WordListMetadata wordlist) {
            DebugLogUtils.l("New UpdateData action for client ", clientId, " : ", wordlist);
            mClientId = clientId;
            mWordList = wordlist;
        }

        @Override
        public void execute(final Context context) {
            if (null == mWordList) { // This should never happen
                Log.e(TAG, "UpdateDataAction with a null word list!");
                return;
            }
            final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId);
            ContentValues oldValues = MetadataDbHelper.getContentValuesByWordListId(db,
                    mWordList.mId, mWordList.mVersion);
            if (null == oldValues) {
                Log.e(TAG, "Trying to update data about a non-existing word list. Bailing out.");
                return;
            }
            DebugLogUtils.l("Updating data about a word list : " + mWordList);
            final ContentValues values = MetadataDbHelper.makeContentValues(
                    oldValues.getAsInteger(MetadataDbHelper.PENDINGID_COLUMN),
                    oldValues.getAsInteger(MetadataDbHelper.TYPE_COLUMN),
                    oldValues.getAsInteger(MetadataDbHelper.STATUS_COLUMN),
                    mWordList.mId, mWordList.mLocale, mWordList.mDescription,
                    oldValues.getAsString(MetadataDbHelper.LOCAL_FILENAME_COLUMN),
                    mWordList.mRemoteFilename, mWordList.mLastUpdate, mWordList.mRawChecksum,
                    mWordList.mChecksum, mWordList.mRetryCount, mWordList.mFileSize,
                    mWordList.mVersion, mWordList.mFormatVersion);
            PrivateLog.log("Updating record for " + mWordList.mDescription
                    + " and locale " + mWordList.mLocale);
            db.update(MetadataDbHelper.METADATA_TABLE_NAME, values,
                    MetadataDbHelper.WORDLISTID_COLUMN + " = ? AND "
                            + MetadataDbHelper.VERSION_COLUMN + " = ?",
                    new String[] { mWordList.mId, Integer.toString(mWordList.mVersion) });
        }
    }

    /**
     * An action that deletes the metadata about a word list if possible.
     *
     * This is triggered when a specific word list disappeared from the server, or when a fresher
     * word list is available and the old one was not installed.
     * If the word list has not been installed, it's possible to delete its associated metadata.
     * Otherwise, the settings are retained so that the user can still administrate it.
     */
    public static final class ForgetAction implements Action {
        static final String TAG = "DictionaryProvider:" + ForgetAction.class.getSimpleName();
        private final String mClientId;
        // The word list to remove. May not be null.
        final WordListMetadata mWordList;
        final boolean mHasNewerVersion;
        public ForgetAction(final String clientId, final WordListMetadata wordlist,
                final boolean hasNewerVersion) {
            DebugLogUtils.l("New TryRemove action for client ", clientId, " : ", wordlist);
            mClientId = clientId;
            mWordList = wordlist;
            mHasNewerVersion = hasNewerVersion;
        }

        @Override
        public void execute(final Context context) {
            if (null == mWordList) { // This should never happen
                Log.e(TAG, "TryRemoveAction with a null word list!");
                return;
            }
            DebugLogUtils.l("Trying to remove word list : " + mWordList);
            final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId);
            final ContentValues values = MetadataDbHelper.getContentValuesByWordListId(db,
                    mWordList.mId, mWordList.mVersion);
            if (null == values) {
                Log.e(TAG, "Trying to update the metadata of a non-existing wordlist. Cancelling.");
                return;
            }
            final int status = values.getAsInteger(MetadataDbHelper.STATUS_COLUMN);
            if (mHasNewerVersion && MetadataDbHelper.STATUS_AVAILABLE != status) {
                // If we have a newer version of this word list, we should be here ONLY if it was
                // not installed - else we should be upgrading it.
                Log.e(TAG, "Unexpected status for forgetting a word list info : " + status
                        + ", removing URL to prevent re-download");
            }
            if (MetadataDbHelper.STATUS_INSTALLED == status
                    || MetadataDbHelper.STATUS_DISABLED == status
                    || MetadataDbHelper.STATUS_DELETING == status) {
                // If it is installed or disabled, we need to mark it as deleted so that LatinIME
                // will remove it next time it enquires for dictionaries.
                // If it is deleting and we don't have a new version, then we have to wait until
                // LatinIME actually has deleted it before we can remove its metadata.
                // In both cases, remove the URI from the database since it is not supposed to
                // be accessible any more.
                values.put(MetadataDbHelper.REMOTE_FILENAME_COLUMN, "");
                values.put(MetadataDbHelper.STATUS_COLUMN, MetadataDbHelper.STATUS_DELETING);
                db.update(MetadataDbHelper.METADATA_TABLE_NAME, values,
                        MetadataDbHelper.WORDLISTID_COLUMN + " = ? AND "
                                + MetadataDbHelper.VERSION_COLUMN + " = ?",
                        new String[] { mWordList.mId, Integer.toString(mWordList.mVersion) });
            } else {
                // If it's AVAILABLE or DOWNLOADING or even UNKNOWN, delete the entry.
                db.delete(MetadataDbHelper.METADATA_TABLE_NAME,
                        MetadataDbHelper.WORDLISTID_COLUMN + " = ? AND "
                                + MetadataDbHelper.VERSION_COLUMN + " = ?",
                        new String[] { mWordList.mId, Integer.toString(mWordList.mVersion) });
            }
        }
    }

    /**
     * An action that sets the word list for deletion as soon as possible.
     *
     * This is triggered when the user requests deletion of a word list. This will mark it as
     * deleted in the database, and fire an intent for Android Keyboard to take notice and
     * reload its dictionaries right away if it is up. If it is not up now, then it will
     * delete the actual file the next time it gets up.
     * A file marked as deleted causes the content provider to supply a zero-sized file to
     * Android Keyboard, which will overwrite any existing file and provide no words for this
     * word list. This is not exactly a "deletion", since there is an actual file which takes up
     * a few bytes on the disk, but this allows to override a default dictionary with an empty
     * dictionary. This way, there is no need for the user to make a distinction between
     * dictionaries installed by default and add-on dictionaries.
     */
    public static final class StartDeleteAction implements Action {
        static final String TAG = "DictionaryProvider:" + StartDeleteAction.class.getSimpleName();
        private final String mClientId;
        // The word list to delete. May not be null.
        final WordListMetadata mWordList;
        public StartDeleteAction(final String clientId, final WordListMetadata wordlist) {
            DebugLogUtils.l("New StartDelete action for client ", clientId, " : ", wordlist);
            mClientId = clientId;
            mWordList = wordlist;
        }

        @Override
        public void execute(final Context context) {
            if (null == mWordList) { // This should never happen
                Log.e(TAG, "StartDeleteAction with a null word list!");
                return;
            }
            DebugLogUtils.l("Trying to delete word list : " + mWordList);
            final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId);
            final ContentValues values = MetadataDbHelper.getContentValuesByWordListId(db,
                    mWordList.mId, mWordList.mVersion);
            if (null == values) {
                Log.e(TAG, "Trying to set a non-existing wordlist for removal. Cancelling.");
                return;
            }
            final int status = values.getAsInteger(MetadataDbHelper.STATUS_COLUMN);
            if (MetadataDbHelper.STATUS_DISABLED != status) {
                Log.e(TAG, "Unexpected status for deleting a word list info : " + status);
            }
            MetadataDbHelper.markEntryAsDeleting(db, mWordList.mId, mWordList.mVersion);
        }
    }

    /**
     * An action that validates a word list as deleted.
     *
     * This will restore the word list as available if it still is, or remove the entry if
     * it is not any more.
     */
    public static final class FinishDeleteAction implements Action {
        static final String TAG = "DictionaryProvider:" + FinishDeleteAction.class.getSimpleName();
        private final String mClientId;
        // The word list to delete. May not be null.
        final WordListMetadata mWordList;
        public FinishDeleteAction(final String clientId, final WordListMetadata wordlist) {
            DebugLogUtils.l("New FinishDelete action for client", clientId, " : ", wordlist);
            mClientId = clientId;
            mWordList = wordlist;
        }

        @Override
        public void execute(final Context context) {
            if (null == mWordList) { // This should never happen
                Log.e(TAG, "FinishDeleteAction with a null word list!");
                return;
            }
            DebugLogUtils.l("Trying to delete word list : " + mWordList);
            final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId);
            final ContentValues values = MetadataDbHelper.getContentValuesByWordListId(db,
                    mWordList.mId, mWordList.mVersion);
            if (null == values) {
                Log.e(TAG, "Trying to set a non-existing wordlist for removal. Cancelling.");
                return;
            }
            final int status = values.getAsInteger(MetadataDbHelper.STATUS_COLUMN);
            if (MetadataDbHelper.STATUS_DELETING != status) {
                Log.e(TAG, "Unexpected status for finish-deleting a word list info : " + status);
            }
            final String remoteFilename =
                    values.getAsString(MetadataDbHelper.REMOTE_FILENAME_COLUMN);
            // If there isn't a remote filename any more, then we don't know where to get the file
            // from any more, so we remove the entry entirely. As a matter of fact, if the file was
            // marked DELETING but disappeared from the metadata on the server, it ended up
            // this way.
            if (TextUtils.isEmpty(remoteFilename)) {
                db.delete(MetadataDbHelper.METADATA_TABLE_NAME,
                        MetadataDbHelper.WORDLISTID_COLUMN + " = ? AND "
                                + MetadataDbHelper.VERSION_COLUMN + " = ?",
                        new String[] { mWordList.mId, Integer.toString(mWordList.mVersion) });
            } else {
                MetadataDbHelper.markEntryAsAvailable(db, mWordList.mId, mWordList.mVersion);
            }
        }
    }

    // An action batch consists of an ordered queue of Actions that can execute.
    private final Queue<Action> mActions;

    public ActionBatch() {
        mActions = new LinkedList<>();
    }

    public void add(final Action a) {
        mActions.add(a);
    }

    /**
     * Append all the actions of another action batch.
     * @param that the upgrade to merge into this one.
     */
    public void append(final ActionBatch that) {
        for (final Action a : that.mActions) {
            add(a);
        }
    }

    /**
     * Execute this batch.
     *
     * @param context the context for getting resources, databases, system services.
     * @param reporter a Reporter to send errors to.
     */
    public void execute(final Context context, final ProblemReporter reporter) {
        DebugLogUtils.l("Executing a batch of actions");
        Queue<Action> remainingActions = mActions;
        while (!remainingActions.isEmpty()) {
            final Action a = remainingActions.poll();
            try {
                a.execute(context);
            } catch (Exception e) {
                if (null != reporter)
                    reporter.report(e);
            }
        }
    }
}