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