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.content.ContentProvider; 20 import android.content.ContentResolver; 21 import android.content.ContentValues; 22 import android.content.Context; 23 import android.content.UriMatcher; 24 import android.content.res.AssetFileDescriptor; 25 import android.database.AbstractCursor; 26 import android.database.Cursor; 27 import android.database.sqlite.SQLiteDatabase; 28 import android.net.Uri; 29 import android.os.ParcelFileDescriptor; 30 import android.text.TextUtils; 31 import android.util.Log; 32 33 import com.android.inputmethod.latin.R; 34 import com.android.inputmethod.latin.common.LocaleUtils; 35 import com.android.inputmethod.latin.utils.DebugLogUtils; 36 37 import java.io.File; 38 import java.io.FileNotFoundException; 39 import java.util.Collection; 40 import java.util.Collections; 41 import java.util.HashMap; 42 43 /** 44 * Provider for dictionaries. 45 * 46 * This class is a ContentProvider exposing all available dictionary data as managed by 47 * the dictionary pack. 48 */ 49 public final class DictionaryProvider extends ContentProvider { 50 private static final String TAG = DictionaryProvider.class.getSimpleName(); 51 public static final boolean DEBUG = false; 52 53 public static final Uri CONTENT_URI = 54 Uri.parse(ContentResolver.SCHEME_CONTENT + "://" + DictionaryPackConstants.AUTHORITY); 55 private static final String QUERY_PARAMETER_MAY_PROMPT_USER = "mayPrompt"; 56 private static final String QUERY_PARAMETER_TRUE = "true"; 57 private static final String QUERY_PARAMETER_DELETE_RESULT = "result"; 58 private static final String QUERY_PARAMETER_FAILURE = "failure"; 59 public static final String QUERY_PARAMETER_PROTOCOL_VERSION = "protocol"; 60 private static final int NO_MATCH = 0; 61 private static final int DICTIONARY_V1_WHOLE_LIST = 1; 62 private static final int DICTIONARY_V1_DICT_INFO = 2; 63 private static final int DICTIONARY_V2_METADATA = 3; 64 private static final int DICTIONARY_V2_WHOLE_LIST = 4; 65 private static final int DICTIONARY_V2_DICT_INFO = 5; 66 private static final int DICTIONARY_V2_DATAFILE = 6; 67 private static final UriMatcher sUriMatcherV1 = new UriMatcher(NO_MATCH); 68 private static final UriMatcher sUriMatcherV2 = new UriMatcher(NO_MATCH); 69 static 70 { 71 sUriMatcherV1.addURI(DictionaryPackConstants.AUTHORITY, "list", DICTIONARY_V1_WHOLE_LIST); 72 sUriMatcherV1.addURI(DictionaryPackConstants.AUTHORITY, "*", DICTIONARY_V1_DICT_INFO); 73 sUriMatcherV2.addURI(DictionaryPackConstants.AUTHORITY, "*/metadata", 74 DICTIONARY_V2_METADATA); 75 sUriMatcherV2.addURI(DictionaryPackConstants.AUTHORITY, "*/list", DICTIONARY_V2_WHOLE_LIST); 76 sUriMatcherV2.addURI(DictionaryPackConstants.AUTHORITY, "*/dict/*", 77 DICTIONARY_V2_DICT_INFO); 78 sUriMatcherV2.addURI(DictionaryPackConstants.AUTHORITY, "*/datafile/*", 79 DICTIONARY_V2_DATAFILE); 80 } 81 82 // MIME types for dictionary and dictionary list, as required by ContentProvider contract. 83 public static final String DICT_LIST_MIME_TYPE = 84 "vnd.android.cursor.item/vnd.google.dictionarylist"; 85 public static final String DICT_DATAFILE_MIME_TYPE = 86 "vnd.android.cursor.item/vnd.google.dictionary"; 87 88 public static final String ID_CATEGORY_SEPARATOR = ":"; 89 90 private static final class WordListInfo { 91 public final String mId; 92 public final String mLocale; 93 public final String mRawChecksum; 94 public final int mMatchLevel; 95 public WordListInfo(final String id, final String locale, final String rawChecksum, 96 final int matchLevel) { 97 mId = id; 98 mLocale = locale; 99 mRawChecksum = rawChecksum; 100 mMatchLevel = matchLevel; 101 } 102 } 103 104 /** 105 * A cursor for returning a list of file ids from a List of strings. 106 * 107 * This simulates only the necessary methods. It has no error handling to speak of, 108 * and does not support everything a database does, only a few select necessary methods. 109 */ 110 private static final class ResourcePathCursor extends AbstractCursor { 111 112 // Column names for the cursor returned by this content provider. 113 static private final String[] columnNames = { MetadataDbHelper.WORDLISTID_COLUMN, 114 MetadataDbHelper.LOCALE_COLUMN, MetadataDbHelper.RAW_CHECKSUM_COLUMN }; 115 116 // The list of word lists served by this provider that match the client request. 117 final WordListInfo[] mWordLists; 118 // Note : the cursor also uses mPos, which is defined in AbstractCursor. 119 120 public ResourcePathCursor(final Collection<WordListInfo> wordLists) { 121 // Allocating a 0-size WordListInfo here allows the toArray() method 122 // to ensure we have a strongly-typed array. It's thrown out. That's 123 // what the documentation of #toArray says to do in order to get a 124 // new strongly typed array of the correct size. 125 mWordLists = wordLists.toArray(new WordListInfo[0]); 126 mPos = 0; 127 } 128 129 @Override 130 public String[] getColumnNames() { 131 return columnNames; 132 } 133 134 @Override 135 public int getCount() { 136 return mWordLists.length; 137 } 138 139 @Override public double getDouble(int column) { return 0; } 140 @Override public float getFloat(int column) { return 0; } 141 @Override public int getInt(int column) { return 0; } 142 @Override public short getShort(int column) { return 0; } 143 @Override public long getLong(int column) { return 0; } 144 145 @Override public String getString(final int column) { 146 switch (column) { 147 case 0: return mWordLists[mPos].mId; 148 case 1: return mWordLists[mPos].mLocale; 149 case 2: return mWordLists[mPos].mRawChecksum; 150 default : return null; 151 } 152 } 153 154 @Override 155 public boolean isNull(final int column) { 156 if (mPos >= mWordLists.length) return true; 157 return column != 0; 158 } 159 } 160 161 @Override 162 public boolean onCreate() { 163 return true; 164 } 165 166 private static int matchUri(final Uri uri) { 167 int protocolVersion = 1; 168 final String protocolVersionArg = uri.getQueryParameter(QUERY_PARAMETER_PROTOCOL_VERSION); 169 if ("2".equals(protocolVersionArg)) protocolVersion = 2; 170 switch (protocolVersion) { 171 case 1: return sUriMatcherV1.match(uri); 172 case 2: return sUriMatcherV2.match(uri); 173 default: return NO_MATCH; 174 } 175 } 176 177 private static String getClientId(final Uri uri) { 178 int protocolVersion = 1; 179 final String protocolVersionArg = uri.getQueryParameter(QUERY_PARAMETER_PROTOCOL_VERSION); 180 if ("2".equals(protocolVersionArg)) protocolVersion = 2; 181 switch (protocolVersion) { 182 case 1: return null; // In protocol 1, the client ID is always null. 183 case 2: return uri.getPathSegments().get(0); 184 default: return null; 185 } 186 } 187 188 /** 189 * Returns the MIME type of the content associated with an Uri 190 * 191 * @see android.content.ContentProvider#getType(android.net.Uri) 192 * 193 * @param uri the URI of the content the type of which should be returned. 194 * @return the MIME type, or null if the URL is not recognized. 195 */ 196 @Override 197 public String getType(final Uri uri) { 198 PrivateLog.log("Asked for type of : " + uri); 199 final int match = matchUri(uri); 200 switch (match) { 201 case NO_MATCH: return null; 202 case DICTIONARY_V1_WHOLE_LIST: 203 case DICTIONARY_V1_DICT_INFO: 204 case DICTIONARY_V2_WHOLE_LIST: 205 case DICTIONARY_V2_DICT_INFO: return DICT_LIST_MIME_TYPE; 206 case DICTIONARY_V2_DATAFILE: return DICT_DATAFILE_MIME_TYPE; 207 default: return null; 208 } 209 } 210 211 /** 212 * Query the provider for dictionary files. 213 * 214 * This version dispatches the query according to the protocol version found in the 215 * ?protocol= query parameter. If absent or not well-formed, it defaults to 1. 216 * @see android.content.ContentProvider#query(Uri, String[], String, String[], String) 217 * 218 * @param uri a content uri (see sUriMatcherV{1,2} at the top of this file for format) 219 * @param projection ignored. All columns are always returned. 220 * @param selection ignored. 221 * @param selectionArgs ignored. 222 * @param sortOrder ignored. The results are always returned in no particular order. 223 * @return a cursor matching the uri, or null if the URI was not recognized. 224 */ 225 @Override 226 public Cursor query(final Uri uri, final String[] projection, final String selection, 227 final String[] selectionArgs, final String sortOrder) { 228 DebugLogUtils.l("Uri =", uri); 229 PrivateLog.log("Query : " + uri); 230 final String clientId = getClientId(uri); 231 final int match = matchUri(uri); 232 switch (match) { 233 case DICTIONARY_V1_WHOLE_LIST: 234 case DICTIONARY_V2_WHOLE_LIST: 235 final Cursor c = MetadataDbHelper.queryDictionaries(getContext(), clientId); 236 DebugLogUtils.l("List of dictionaries with count", c.getCount()); 237 PrivateLog.log("Returned a list of " + c.getCount() + " items"); 238 return c; 239 case DICTIONARY_V2_DICT_INFO: 240 // In protocol version 2, we return null if the client is unknown. Otherwise 241 // we behave exactly like for protocol 1. 242 if (!MetadataDbHelper.isClientKnown(getContext(), clientId)) return null; 243 // Fall through 244 case DICTIONARY_V1_DICT_INFO: 245 final String locale = uri.getLastPathSegment(); 246 final Collection<WordListInfo> dictFiles = 247 getDictionaryWordListsForLocale(clientId, locale); 248 // TODO: pass clientId to the following function 249 DictionaryService.updateNowIfNotUpdatedInAVeryLongTime(getContext()); 250 if (null != dictFiles && dictFiles.size() > 0) { 251 PrivateLog.log("Returned " + dictFiles.size() + " files"); 252 return new ResourcePathCursor(dictFiles); 253 } 254 PrivateLog.log("No dictionary files for this URL"); 255 return new ResourcePathCursor(Collections.<WordListInfo>emptyList()); 256 // V2_METADATA and V2_DATAFILE are not supported for query() 257 default: 258 return null; 259 } 260 } 261 262 /** 263 * Helper method to get the wordlist metadata associated with a wordlist ID. 264 * 265 * @param clientId the ID of the client 266 * @param wordlistId the ID of the wordlist for which to get the metadata. 267 * @return the metadata for this wordlist ID, or null if none could be found. 268 */ 269 private ContentValues getWordlistMetadataForWordlistId(final String clientId, 270 final String wordlistId) { 271 final Context context = getContext(); 272 if (TextUtils.isEmpty(wordlistId)) return null; 273 final SQLiteDatabase db = MetadataDbHelper.getDb(context, clientId); 274 return MetadataDbHelper.getInstalledOrDeletingWordListContentValuesByWordListId( 275 db, wordlistId); 276 } 277 278 /** 279 * Opens an asset file for an URI. 280 * 281 * Called by {@link android.content.ContentResolver#openAssetFileDescriptor(Uri, String)} or 282 * {@link android.content.ContentResolver#openInputStream(Uri)} from a client requesting a 283 * dictionary. 284 * @see android.content.ContentProvider#openAssetFile(Uri, String) 285 * 286 * @param uri the URI the file is for. 287 * @param mode the mode to read the file. MUST be "r" for readonly. 288 * @return the descriptor, or null if the file is not found or if mode is not equals to "r". 289 */ 290 @Override 291 public AssetFileDescriptor openAssetFile(final Uri uri, final String mode) { 292 if (null == mode || !"r".equals(mode)) return null; 293 294 final int match = matchUri(uri); 295 if (DICTIONARY_V1_DICT_INFO != match && DICTIONARY_V2_DATAFILE != match) { 296 // Unsupported URI for openAssetFile 297 Log.w(TAG, "Unsupported URI for openAssetFile : " + uri); 298 return null; 299 } 300 final String wordlistId = uri.getLastPathSegment(); 301 final String clientId = getClientId(uri); 302 final ContentValues wordList = getWordlistMetadataForWordlistId(clientId, wordlistId); 303 304 if (null == wordList) return null; 305 306 try { 307 final int status = wordList.getAsInteger(MetadataDbHelper.STATUS_COLUMN); 308 if (MetadataDbHelper.STATUS_DELETING == status) { 309 // This will return an empty file (R.raw.empty points at an empty dictionary) 310 // This is how we "delete" the files. It allows Android Keyboard to fake deleting 311 // a default dictionary - which is actually in its assets and can't be really 312 // deleted. 313 final AssetFileDescriptor afd = getContext().getResources().openRawResourceFd( 314 R.raw.empty); 315 return afd; 316 } 317 final String localFilename = 318 wordList.getAsString(MetadataDbHelper.LOCAL_FILENAME_COLUMN); 319 final File f = getContext().getFileStreamPath(localFilename); 320 final ParcelFileDescriptor pfd = 321 ParcelFileDescriptor.open(f, ParcelFileDescriptor.MODE_READ_ONLY); 322 return new AssetFileDescriptor(pfd, 0, pfd.getStatSize()); 323 } catch (FileNotFoundException e) { 324 // No file : fall through and return null 325 } 326 return null; 327 } 328 329 /** 330 * Reads the metadata and returns the collection of dictionaries for a given locale. 331 * 332 * Word list IDs are expected to be in the form category:manual_id. This method 333 * will select only one word list for each category: the one with the most specific 334 * locale matching the locale specified in the URI. The manual id serves only to 335 * distinguish a word list from another for the purpose of updating, and is arbitrary 336 * but may not contain a colon. 337 * 338 * @param clientId the ID of the client requesting the list 339 * @param locale the locale for which we want the list, as a String 340 * @return a collection of ids. It is guaranteed to be non-null, but may be empty. 341 */ 342 private Collection<WordListInfo> getDictionaryWordListsForLocale(final String clientId, 343 final String locale) { 344 final Context context = getContext(); 345 final Cursor results = 346 MetadataDbHelper.queryInstalledOrDeletingOrAvailableDictionaryMetadata(context, 347 clientId); 348 if (null == results) { 349 return Collections.<WordListInfo>emptyList(); 350 } 351 try { 352 final HashMap<String, WordListInfo> dicts = new HashMap<>(); 353 final int idIndex = results.getColumnIndex(MetadataDbHelper.WORDLISTID_COLUMN); 354 final int localeIndex = results.getColumnIndex(MetadataDbHelper.LOCALE_COLUMN); 355 final int localFileNameIndex = 356 results.getColumnIndex(MetadataDbHelper.LOCAL_FILENAME_COLUMN); 357 final int rawChecksumIndex = 358 results.getColumnIndex(MetadataDbHelper.RAW_CHECKSUM_COLUMN); 359 final int statusIndex = results.getColumnIndex(MetadataDbHelper.STATUS_COLUMN); 360 if (results.moveToFirst()) { 361 do { 362 final String wordListId = results.getString(idIndex); 363 if (TextUtils.isEmpty(wordListId)) continue; 364 final String[] wordListIdArray = 365 TextUtils.split(wordListId, ID_CATEGORY_SEPARATOR); 366 final String wordListCategory; 367 if (2 == wordListIdArray.length) { 368 // This is at the category:manual_id format. 369 wordListCategory = wordListIdArray[0]; 370 // We don't need to read wordListIdArray[1] here, because it's irrelevant to 371 // word list selection - it's just a name we use to identify which data file 372 // is a newer version of which word list. We do however return the full id 373 // string for each selected word list, so in this sense we are 'using' it. 374 } else { 375 // This does not contain a colon, like the old format does. Old-format IDs 376 // always point to main dictionaries, so we force the main category upon it. 377 wordListCategory = UpdateHandler.MAIN_DICTIONARY_CATEGORY; 378 } 379 final String wordListLocale = results.getString(localeIndex); 380 final String wordListLocalFilename = results.getString(localFileNameIndex); 381 final String wordListRawChecksum = results.getString(rawChecksumIndex); 382 final int wordListStatus = results.getInt(statusIndex); 383 // Test the requested locale against this wordlist locale. The requested locale 384 // has to either match exactly or be more specific than the dictionary - a 385 // dictionary for "en" would match both a request for "en" or for "en_US", but a 386 // dictionary for "en_GB" would not match a request for "en_US". Thus if all 387 // three of "en" "en_US" and "en_GB" dictionaries are installed, a request for 388 // "en_US" would match "en" and "en_US", and a request for "en" only would only 389 // match the generic "en" dictionary. For more details, see the documentation 390 // for LocaleUtils#getMatchLevel. 391 final int matchLevel = LocaleUtils.getMatchLevel(wordListLocale, locale); 392 if (!LocaleUtils.isMatch(matchLevel)) { 393 // The locale of this wordlist does not match the required locale. 394 // Skip this wordlist and go to the next. 395 continue; 396 } 397 if (MetadataDbHelper.STATUS_INSTALLED == wordListStatus) { 398 // If the file does not exist, it has been deleted and the IME should 399 // already have it. Do not return it. However, this only applies if the 400 // word list is INSTALLED, for if it is DELETING we should return it always 401 // so that Android Keyboard can perform the actual deletion. 402 final File f = getContext().getFileStreamPath(wordListLocalFilename); 403 if (!f.isFile()) { 404 continue; 405 } 406 } else if (MetadataDbHelper.STATUS_AVAILABLE == wordListStatus) { 407 // The locale is the id for the main dictionary. 408 UpdateHandler.installIfNeverRequested(context, clientId, wordListId); 409 continue; 410 } 411 final WordListInfo currentBestMatch = dicts.get(wordListCategory); 412 if (null == currentBestMatch 413 || currentBestMatch.mMatchLevel < matchLevel) { 414 dicts.put(wordListCategory, new WordListInfo(wordListId, wordListLocale, 415 wordListRawChecksum, matchLevel)); 416 } 417 } while (results.moveToNext()); 418 } 419 return Collections.unmodifiableCollection(dicts.values()); 420 } finally { 421 results.close(); 422 } 423 } 424 425 /** 426 * Deletes the file pointed by Uri, as returned by openAssetFile. 427 * 428 * @param uri the URI the file is for. 429 * @param selection ignored 430 * @param selectionArgs ignored 431 * @return the number of files deleted (0 or 1 in the current implementation) 432 * @see android.content.ContentProvider#delete(Uri, String, String[]) 433 */ 434 @Override 435 public int delete(final Uri uri, final String selection, final String[] selectionArgs) 436 throws UnsupportedOperationException { 437 final int match = matchUri(uri); 438 if (DICTIONARY_V1_DICT_INFO == match || DICTIONARY_V2_DATAFILE == match) { 439 return deleteDataFile(uri); 440 } 441 if (DICTIONARY_V2_METADATA == match) { 442 if (MetadataDbHelper.deleteClient(getContext(), getClientId(uri))) { 443 return 1; 444 } 445 return 0; 446 } 447 // Unsupported URI for delete 448 return 0; 449 } 450 451 private int deleteDataFile(final Uri uri) { 452 final String wordlistId = uri.getLastPathSegment(); 453 final String clientId = getClientId(uri); 454 final ContentValues wordList = getWordlistMetadataForWordlistId(clientId, wordlistId); 455 if (null == wordList) { 456 return 0; 457 } 458 final int status = wordList.getAsInteger(MetadataDbHelper.STATUS_COLUMN); 459 final int version = wordList.getAsInteger(MetadataDbHelper.VERSION_COLUMN); 460 if (MetadataDbHelper.STATUS_DELETING == status) { 461 UpdateHandler.markAsDeleted(getContext(), clientId, wordlistId, version, status); 462 return 1; 463 } 464 if (MetadataDbHelper.STATUS_INSTALLED == status) { 465 final String result = uri.getQueryParameter(QUERY_PARAMETER_DELETE_RESULT); 466 if (QUERY_PARAMETER_FAILURE.equals(result)) { 467 if (DEBUG) { 468 Log.d(TAG, 469 "Dictionary is broken, attempting to retry download & installation."); 470 } 471 UpdateHandler.markAsBrokenOrRetrying(getContext(), clientId, wordlistId, version); 472 } 473 final String localFilename = 474 wordList.getAsString(MetadataDbHelper.LOCAL_FILENAME_COLUMN); 475 final File f = getContext().getFileStreamPath(localFilename); 476 // f.delete() returns true if the file was successfully deleted, false otherwise 477 return f.delete() ? 1 : 0; 478 } 479 Log.e(TAG, "Attempt to delete a file whose status is " + status); 480 return 0; 481 } 482 483 /** 484 * Insert data into the provider. May be either a metadata source URL or some dictionary info. 485 * 486 * @param uri the designated content URI. See sUriMatcherV{1,2} for available URIs. 487 * @param values the values to insert for this content uri 488 * @return the URI for the newly inserted item. May be null if arguments don't allow for insert 489 */ 490 @Override 491 public Uri insert(final Uri uri, final ContentValues values) 492 throws UnsupportedOperationException { 493 if (null == uri || null == values) return null; // Should never happen but let's be safe 494 PrivateLog.log("Insert, uri = " + uri.toString()); 495 final String clientId = getClientId(uri); 496 switch (matchUri(uri)) { 497 case DICTIONARY_V2_METADATA: 498 // The values should contain a valid client ID and a valid URI for the metadata. 499 // The client ID may not be null, nor may it be empty because the empty client ID 500 // is reserved for internal use. 501 // The metadata URI may not be null, but it may be empty if the client does not 502 // want the dictionary pack to update the metadata automatically. 503 MetadataDbHelper.updateClientInfo(getContext(), clientId, values); 504 break; 505 case DICTIONARY_V2_DICT_INFO: 506 try { 507 final WordListMetadata newDictionaryMetadata = 508 WordListMetadata.createFromContentValues( 509 MetadataDbHelper.completeWithDefaultValues(values)); 510 new ActionBatch.MarkPreInstalledAction(clientId, newDictionaryMetadata) 511 .execute(getContext()); 512 } catch (final BadFormatException e) { 513 Log.w(TAG, "Not enough information to insert this dictionary " + values, e); 514 } 515 // We just received new information about the list of dictionary for this client. 516 // For all intents and purposes, this is new metadata, so we should publish it 517 // so that any listeners (like the Settings interface for example) can update 518 // themselves. 519 UpdateHandler.publishUpdateMetadataCompleted(getContext(), true); 520 break; 521 case DICTIONARY_V1_WHOLE_LIST: 522 case DICTIONARY_V1_DICT_INFO: 523 PrivateLog.log("Attempt to insert : " + uri); 524 throw new UnsupportedOperationException( 525 "Insertion in the dictionary is not supported in this version"); 526 } 527 return uri; 528 } 529 530 /** 531 * Updating data is not supported, and will throw an exception. 532 * @see android.content.ContentProvider#update(Uri, ContentValues, String, String[]) 533 * @see android.content.ContentProvider#insert(Uri, ContentValues) 534 */ 535 @Override 536 public int update(final Uri uri, final ContentValues values, final String selection, 537 final String[] selectionArgs) throws UnsupportedOperationException { 538 PrivateLog.log("Attempt to update : " + uri); 539 throw new UnsupportedOperationException("Updating dictionary words is not supported"); 540 } 541 } 542