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