DictionaryProvider.java revision 03118a276014cd44d44d0d46f4f39622765e8e0c
1/** 2 * Copyright (C) 2011 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 * use this file except in compliance with the License. You may obtain a copy of 6 * the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 * License for the specific language governing permissions and limitations under 14 * the License. 15 */ 16 17package com.android.inputmethod.dictionarypack; 18 19import android.content.ContentProvider; 20import android.content.ContentResolver; 21import android.content.ContentValues; 22import android.content.Context; 23import android.content.UriMatcher; 24import android.content.res.AssetFileDescriptor; 25import android.database.AbstractCursor; 26import android.database.Cursor; 27import android.database.sqlite.SQLiteDatabase; 28import android.net.Uri; 29import android.os.ParcelFileDescriptor; 30import android.text.TextUtils; 31import android.util.Log; 32 33import com.android.inputmethod.latin.R; 34import com.android.inputmethod.latin.utils.DebugLogUtils; 35 36import java.io.File; 37import java.io.FileNotFoundException; 38import java.util.Collection; 39import java.util.Collections; 40import java.util.HashMap; 41 42/** 43 * Provider for dictionaries. 44 * 45 * This class is a ContentProvider exposing all available dictionary data as managed by 46 * the dictionary pack. 47 */ 48public final class DictionaryProvider extends ContentProvider { 49 private static final String TAG = DictionaryProvider.class.getSimpleName(); 50 public static final boolean DEBUG = false; 51 52 public static final Uri CONTENT_URI = 53 Uri.parse(ContentResolver.SCHEME_CONTENT + "://" + DictionaryPackConstants.AUTHORITY); 54 private static final String QUERY_PARAMETER_MAY_PROMPT_USER = "mayPrompt"; 55 private static final String QUERY_PARAMETER_TRUE = "true"; 56 private static final String QUERY_PARAMETER_DELETE_RESULT = "result"; 57 private static final String QUERY_PARAMETER_SUCCESS = "success"; 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 int mMatchLevel; 94 public WordListInfo(final String id, final String locale, final int matchLevel) { 95 mId = id; 96 mLocale = locale; 97 mMatchLevel = matchLevel; 98 } 99 } 100 101 /** 102 * A cursor for returning a list of file ids from a List of strings. 103 * 104 * This simulates only the necessary methods. It has no error handling to speak of, 105 * and does not support everything a database does, only a few select necessary methods. 106 */ 107 private static final class ResourcePathCursor extends AbstractCursor { 108 109 // Column names for the cursor returned by this content provider. 110 static private final String[] columnNames = { "id", "locale" }; 111 112 // The list of word lists served by this provider that match the client request. 113 final WordListInfo[] mWordLists; 114 // Note : the cursor also uses mPos, which is defined in AbstractCursor. 115 116 public ResourcePathCursor(final Collection<WordListInfo> wordLists) { 117 // Allocating a 0-size WordListInfo here allows the toArray() method 118 // to ensure we have a strongly-typed array. It's thrown out. That's 119 // what the documentation of #toArray says to do in order to get a 120 // new strongly typed array of the correct size. 121 mWordLists = wordLists.toArray(new WordListInfo[0]); 122 mPos = 0; 123 } 124 125 @Override 126 public String[] getColumnNames() { 127 return columnNames; 128 } 129 130 @Override 131 public int getCount() { 132 return mWordLists.length; 133 } 134 135 @Override public double getDouble(int column) { return 0; } 136 @Override public float getFloat(int column) { return 0; } 137 @Override public int getInt(int column) { return 0; } 138 @Override public short getShort(int column) { return 0; } 139 @Override public long getLong(int column) { return 0; } 140 141 @Override public String getString(final int column) { 142 switch (column) { 143 case 0: return mWordLists[mPos].mId; 144 case 1: return mWordLists[mPos].mLocale; 145 default : return null; 146 } 147 } 148 149 @Override 150 public boolean isNull(final int column) { 151 if (mPos >= mWordLists.length) return true; 152 return column != 0; 153 } 154 } 155 156 @Override 157 public boolean onCreate() { 158 return true; 159 } 160 161 private static int matchUri(final Uri uri) { 162 int protocolVersion = 1; 163 final String protocolVersionArg = uri.getQueryParameter(QUERY_PARAMETER_PROTOCOL_VERSION); 164 if ("2".equals(protocolVersionArg)) protocolVersion = 2; 165 switch (protocolVersion) { 166 case 1: return sUriMatcherV1.match(uri); 167 case 2: return sUriMatcherV2.match(uri); 168 default: return NO_MATCH; 169 } 170 } 171 172 private static String getClientId(final Uri uri) { 173 int protocolVersion = 1; 174 final String protocolVersionArg = uri.getQueryParameter(QUERY_PARAMETER_PROTOCOL_VERSION); 175 if ("2".equals(protocolVersionArg)) protocolVersion = 2; 176 switch (protocolVersion) { 177 case 1: return null; // In protocol 1, the client ID is always null. 178 case 2: return uri.getPathSegments().get(0); 179 default: return null; 180 } 181 } 182 183 /** 184 * Returns the MIME type of the content associated with an Uri 185 * 186 * @see android.content.ContentProvider#getType(android.net.Uri) 187 * 188 * @param uri the URI of the content the type of which should be returned. 189 * @return the MIME type, or null if the URL is not recognized. 190 */ 191 @Override 192 public String getType(final Uri uri) { 193 PrivateLog.log("Asked for type of : " + uri); 194 final int match = matchUri(uri); 195 switch (match) { 196 case NO_MATCH: return null; 197 case DICTIONARY_V1_WHOLE_LIST: 198 case DICTIONARY_V1_DICT_INFO: 199 case DICTIONARY_V2_WHOLE_LIST: 200 case DICTIONARY_V2_DICT_INFO: return DICT_LIST_MIME_TYPE; 201 case DICTIONARY_V2_DATAFILE: return DICT_DATAFILE_MIME_TYPE; 202 default: return null; 203 } 204 } 205 206 /** 207 * Query the provider for dictionary files. 208 * 209 * This version dispatches the query according to the protocol version found in the 210 * ?protocol= query parameter. If absent or not well-formed, it defaults to 1. 211 * @see android.content.ContentProvider#query(Uri, String[], String, String[], String) 212 * 213 * @param uri a content uri (see sUriMatcherV{1,2} at the top of this file for format) 214 * @param projection ignored. All columns are always returned. 215 * @param selection ignored. 216 * @param selectionArgs ignored. 217 * @param sortOrder ignored. The results are always returned in no particular order. 218 * @return a cursor matching the uri, or null if the URI was not recognized. 219 */ 220 @Override 221 public Cursor query(final Uri uri, final String[] projection, final String selection, 222 final String[] selectionArgs, final String sortOrder) { 223 DebugLogUtils.l("Uri =", uri); 224 PrivateLog.log("Query : " + uri); 225 final String clientId = getClientId(uri); 226 final int match = matchUri(uri); 227 switch (match) { 228 case DICTIONARY_V1_WHOLE_LIST: 229 case DICTIONARY_V2_WHOLE_LIST: 230 final Cursor c = MetadataDbHelper.queryDictionaries(getContext(), clientId); 231 DebugLogUtils.l("List of dictionaries with count", c.getCount()); 232 PrivateLog.log("Returned a list of " + c.getCount() + " items"); 233 return c; 234 case DICTIONARY_V2_DICT_INFO: 235 // In protocol version 2, we return null if the client is unknown. Otherwise 236 // we behave exactly like for protocol 1. 237 if (!MetadataDbHelper.isClientKnown(getContext(), clientId)) return null; 238 // Fall through 239 case DICTIONARY_V1_DICT_INFO: 240 final String locale = uri.getLastPathSegment(); 241 // If LatinIME does not have a dictionary for this locale at all, it will 242 // send us true for this value. In this case, we may prompt the user for 243 // a decision about downloading a dictionary even over a metered connection. 244 final String mayPromptValue = 245 uri.getQueryParameter(QUERY_PARAMETER_MAY_PROMPT_USER); 246 final boolean mayPrompt = QUERY_PARAMETER_TRUE.equals(mayPromptValue); 247 final Collection<WordListInfo> dictFiles = 248 getDictionaryWordListsForLocale(clientId, locale, mayPrompt); 249 // TODO: pass clientId to the following function 250 DictionaryService.updateNowIfNotUpdatedInAVeryLongTime(getContext()); 251 if (null != dictFiles && dictFiles.size() > 0) { 252 PrivateLog.log("Returned " + dictFiles.size() + " files"); 253 return new ResourcePathCursor(dictFiles); 254 } else { 255 PrivateLog.log("No dictionary files for this URL"); 256 return new ResourcePathCursor(Collections.<WordListInfo>emptyList()); 257 } 258 // V2_METADATA and V2_DATAFILE are not supported for query() 259 default: 260 return null; 261 } 262 } 263 264 /** 265 * Helper method to get the wordlist metadata associated with a wordlist ID. 266 * 267 * @param clientId the ID of the client 268 * @param wordlistId the ID of the wordlist for which to get the metadata. 269 * @return the metadata for this wordlist ID, or null if none could be found. 270 */ 271 private ContentValues getWordlistMetadataForWordlistId(final String clientId, 272 final String wordlistId) { 273 final Context context = getContext(); 274 if (TextUtils.isEmpty(wordlistId)) return null; 275 final SQLiteDatabase db = MetadataDbHelper.getDb(context, clientId); 276 return MetadataDbHelper.getInstalledOrDeletingWordListContentValuesByWordListId( 277 db, wordlistId); 278 } 279 280 /** 281 * Opens an asset file for an URI. 282 * 283 * Called by {@link android.content.ContentResolver#openAssetFileDescriptor(Uri, String)} or 284 * {@link android.content.ContentResolver#openInputStream(Uri)} from a client requesting a 285 * dictionary. 286 * @see android.content.ContentProvider#openAssetFile(Uri, String) 287 * 288 * @param uri the URI the file is for. 289 * @param mode the mode to read the file. MUST be "r" for readonly. 290 * @return the descriptor, or null if the file is not found or if mode is not equals to "r". 291 */ 292 @Override 293 public AssetFileDescriptor openAssetFile(final Uri uri, final String mode) { 294 if (null == mode || !"r".equals(mode)) return null; 295 296 final int match = matchUri(uri); 297 if (DICTIONARY_V1_DICT_INFO != match && DICTIONARY_V2_DATAFILE != match) { 298 // Unsupported URI for openAssetFile 299 Log.w(TAG, "Unsupported URI for openAssetFile : " + uri); 300 return null; 301 } 302 final String wordlistId = uri.getLastPathSegment(); 303 final String clientId = getClientId(uri); 304 final ContentValues wordList = getWordlistMetadataForWordlistId(clientId, wordlistId); 305 306 if (null == wordList) return null; 307 308 try { 309 final int status = wordList.getAsInteger(MetadataDbHelper.STATUS_COLUMN); 310 if (MetadataDbHelper.STATUS_DELETING == status) { 311 // This will return an empty file (R.raw.empty points at an empty dictionary) 312 // This is how we "delete" the files. It allows Android Keyboard to fake deleting 313 // a default dictionary - which is actually in its assets and can't be really 314 // deleted. 315 final AssetFileDescriptor afd = getContext().getResources().openRawResourceFd( 316 R.raw.empty); 317 return afd; 318 } else { 319 final String localFilename = 320 wordList.getAsString(MetadataDbHelper.LOCAL_FILENAME_COLUMN); 321 final File f = getContext().getFileStreamPath(localFilename); 322 final ParcelFileDescriptor pfd = 323 ParcelFileDescriptor.open(f, ParcelFileDescriptor.MODE_READ_ONLY); 324 return new AssetFileDescriptor(pfd, 0, pfd.getStatSize()); 325 } 326 } catch (FileNotFoundException e) { 327 // No file : fall through and return null 328 } 329 return null; 330 } 331 332 /** 333 * Reads the metadata and returns the collection of dictionaries for a given locale. 334 * 335 * Word list IDs are expected to be in the form category:manual_id. This method 336 * will select only one word list for each category: the one with the most specific 337 * locale matching the locale specified in the URI. The manual id serves only to 338 * distinguish a word list from another for the purpose of updating, and is arbitrary 339 * but may not contain a colon. 340 * 341 * @param clientId the ID of the client requesting the list 342 * @param locale the locale for which we want the list, as a String 343 * @param mayPrompt true if we are allowed to prompt the user for arbitration via notification 344 * @return a collection of ids. It is guaranteed to be non-null, but may be empty. 345 */ 346 private Collection<WordListInfo> getDictionaryWordListsForLocale(final String clientId, 347 final String locale, final boolean mayPrompt) { 348 final Context context = getContext(); 349 final Cursor results = 350 MetadataDbHelper.queryInstalledOrDeletingOrAvailableDictionaryMetadata(context, 351 clientId); 352 if (null == results) { 353 return Collections.<WordListInfo>emptyList(); 354 } else { 355 final HashMap<String, WordListInfo> dicts = new HashMap<String, WordListInfo>(); 356 final int idIndex = results.getColumnIndex(MetadataDbHelper.WORDLISTID_COLUMN); 357 final int localeIndex = results.getColumnIndex(MetadataDbHelper.LOCALE_COLUMN); 358 final int localFileNameIndex = 359 results.getColumnIndex(MetadataDbHelper.LOCAL_FILENAME_COLUMN); 360 final int statusIndex = results.getColumnIndex(MetadataDbHelper.STATUS_COLUMN); 361 if (results.moveToFirst()) { 362 do { 363 final String wordListId = results.getString(idIndex); 364 if (TextUtils.isEmpty(wordListId)) continue; 365 final String[] wordListIdArray = 366 TextUtils.split(wordListId, ID_CATEGORY_SEPARATOR); 367 final String wordListCategory; 368 if (2 == wordListIdArray.length) { 369 // This is at the category:manual_id format. 370 wordListCategory = wordListIdArray[0]; 371 // We don't need to read wordListIdArray[1] here, because it's irrelevant to 372 // word list selection - it's just a name we use to identify which data file 373 // is a newer version of which word list. We do however return the full id 374 // string for each selected word list, so in this sense we are 'using' it. 375 } else { 376 // This does not contain a colon, like the old format does. Old-format IDs 377 // always point to main dictionaries, so we force the main category upon it. 378 wordListCategory = UpdateHandler.MAIN_DICTIONARY_CATEGORY; 379 } 380 final String wordListLocale = results.getString(localeIndex); 381 final String wordListLocalFilename = results.getString(localFileNameIndex); 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 mayPrompt); 410 continue; 411 } 412 final WordListInfo currentBestMatch = dicts.get(wordListCategory); 413 if (null == currentBestMatch 414 || currentBestMatch.mMatchLevel < matchLevel) { 415 dicts.put(wordListCategory, 416 new WordListInfo(wordListId, wordListLocale, matchLevel)); 417 } 418 } while (results.moveToNext()); 419 } 420 results.close(); 421 return Collections.unmodifiableCollection(dicts.values()); 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) return 0; 456 final int status = wordList.getAsInteger(MetadataDbHelper.STATUS_COLUMN); 457 final int version = wordList.getAsInteger(MetadataDbHelper.VERSION_COLUMN); 458 if (MetadataDbHelper.STATUS_DELETING == status) { 459 UpdateHandler.markAsDeleted(getContext(), clientId, wordlistId, version, status); 460 return 1; 461 } else if (MetadataDbHelper.STATUS_INSTALLED == status) { 462 final String result = uri.getQueryParameter(QUERY_PARAMETER_DELETE_RESULT); 463 if (QUERY_PARAMETER_FAILURE.equals(result)) { 464 UpdateHandler.markAsBroken(getContext(), clientId, wordlistId, version); 465 } 466 final String localFilename = 467 wordList.getAsString(MetadataDbHelper.LOCAL_FILENAME_COLUMN); 468 final File f = getContext().getFileStreamPath(localFilename); 469 // f.delete() returns true if the file was successfully deleted, false otherwise 470 if (f.delete()) { 471 return 1; 472 } else { 473 return 0; 474 } 475 } else { 476 Log.e(TAG, "Attempt to delete a file whose status is " + status); 477 return 0; 478 } 479 } 480 481 /** 482 * Insert data into the provider. May be either a metadata source URL or some dictionary info. 483 * 484 * @param uri the designated content URI. See sUriMatcherV{1,2} for available URIs. 485 * @param values the values to insert for this content uri 486 * @return the URI for the newly inserted item. May be null if arguments don't allow for insert 487 */ 488 @Override 489 public Uri insert(final Uri uri, final ContentValues values) 490 throws UnsupportedOperationException { 491 if (null == uri || null == values) return null; // Should never happen but let's be safe 492 PrivateLog.log("Insert, uri = " + uri.toString()); 493 final String clientId = getClientId(uri); 494 switch (matchUri(uri)) { 495 case DICTIONARY_V2_METADATA: 496 // The values should contain a valid client ID and a valid URI for the metadata. 497 // The client ID may not be null, nor may it be empty because the empty client ID 498 // is reserved for internal use. 499 // The metadata URI may not be null, but it may be empty if the client does not 500 // want the dictionary pack to update the metadata automatically. 501 MetadataDbHelper.updateClientInfo(getContext(), clientId, values); 502 break; 503 case DICTIONARY_V2_DICT_INFO: 504 try { 505 final WordListMetadata newDictionaryMetadata = 506 WordListMetadata.createFromContentValues( 507 MetadataDbHelper.completeWithDefaultValues(values)); 508 new ActionBatch.MarkPreInstalledAction(clientId, newDictionaryMetadata) 509 .execute(getContext()); 510 } catch (final BadFormatException e) { 511 Log.w(TAG, "Not enough information to insert this dictionary " + values, e); 512 } 513 // We just received new information about the list of dictionary for this client. 514 // For all intents and purposes, this is new metadata, so we should publish it 515 // so that any listeners (like the Settings interface for example) can update 516 // themselves. 517 UpdateHandler.publishUpdateMetadataCompleted(getContext(), true); 518 break; 519 case DICTIONARY_V1_WHOLE_LIST: 520 case DICTIONARY_V1_DICT_INFO: 521 PrivateLog.log("Attempt to insert : " + uri); 522 throw new UnsupportedOperationException( 523 "Insertion in the dictionary is not supported in this version"); 524 } 525 return uri; 526 } 527 528 /** 529 * Updating data is not supported, and will throw an exception. 530 * @see android.content.ContentProvider#update(Uri, ContentValues, String, String[]) 531 * @see android.content.ContentProvider#insert(Uri, ContentValues) 532 */ 533 @Override 534 public int update(final Uri uri, final ContentValues values, final String selection, 535 final String[] selectionArgs) throws UnsupportedOperationException { 536 PrivateLog.log("Attempt to update : " + uri); 537 throw new UnsupportedOperationException("Updating dictionary words is not supported"); 538 } 539} 540