SettingsProvider.java revision 547a96bc12f25f585271c678395d4c991f08c52d
1/* 2 * Copyright (C) 2007 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of 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, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17package com.android.providers.settings; 18 19import java.io.FileNotFoundException; 20import java.io.UnsupportedEncodingException; 21import java.security.NoSuchAlgorithmException; 22import java.security.SecureRandom; 23import java.util.LinkedHashMap; 24import java.util.Map; 25 26import android.app.backup.BackupManager; 27import android.content.ContentProvider; 28import android.content.ContentUris; 29import android.content.ContentValues; 30import android.content.Context; 31import android.content.pm.PackageManager; 32import android.content.res.AssetFileDescriptor; 33import android.database.Cursor; 34import android.database.sqlite.SQLiteDatabase; 35import android.database.sqlite.SQLiteException; 36import android.database.sqlite.SQLiteQueryBuilder; 37import android.media.RingtoneManager; 38import android.net.Uri; 39import android.os.Bundle; 40import android.os.ParcelFileDescriptor; 41import android.os.SystemProperties; 42import android.provider.DrmStore; 43import android.provider.MediaStore; 44import android.provider.Settings; 45import android.text.TextUtils; 46import android.util.Log; 47 48public class SettingsProvider extends ContentProvider { 49 private static final String TAG = "SettingsProvider"; 50 private static final boolean LOCAL_LOGV = false; 51 52 private static final String TABLE_FAVORITES = "favorites"; 53 private static final String TABLE_OLD_FAVORITES = "old_favorites"; 54 55 private static final String[] COLUMN_VALUE = new String[] { "value" }; 56 57 // Cache for settings, access-ordered for acting as LRU. 58 // Guarded by themselves. 59 private static final int MAX_CACHE_ENTRIES = 50; 60 private static final SettingsCache sSystemCache = new SettingsCache(); 61 private static final SettingsCache sSecureCache = new SettingsCache(); 62 63 // Over this size we don't reject loading or saving settings but 64 // we do consider them broken/malicious and don't keep them in 65 // memory at least: 66 private static final int MAX_CACHE_ENTRY_SIZE = 500; 67 68 private static final Bundle NULL_SETTING = Bundle.forPair("value", null); 69 70 protected DatabaseHelper mOpenHelper; 71 private BackupManager mBackupManager; 72 73 /** 74 * Decode a content URL into the table, projection, and arguments 75 * used to access the corresponding database rows. 76 */ 77 private static class SqlArguments { 78 public String table; 79 public final String where; 80 public final String[] args; 81 82 /** Operate on existing rows. */ 83 SqlArguments(Uri url, String where, String[] args) { 84 if (url.getPathSegments().size() == 1) { 85 this.table = url.getPathSegments().get(0); 86 this.where = where; 87 this.args = args; 88 } else if (url.getPathSegments().size() != 2) { 89 throw new IllegalArgumentException("Invalid URI: " + url); 90 } else if (!TextUtils.isEmpty(where)) { 91 throw new UnsupportedOperationException("WHERE clause not supported: " + url); 92 } else { 93 this.table = url.getPathSegments().get(0); 94 if ("system".equals(this.table) || "secure".equals(this.table)) { 95 this.where = Settings.NameValueTable.NAME + "=?"; 96 this.args = new String[] { url.getPathSegments().get(1) }; 97 } else { 98 this.where = "_id=" + ContentUris.parseId(url); 99 this.args = null; 100 } 101 } 102 } 103 104 /** Insert new rows (no where clause allowed). */ 105 SqlArguments(Uri url) { 106 if (url.getPathSegments().size() == 1) { 107 this.table = url.getPathSegments().get(0); 108 this.where = null; 109 this.args = null; 110 } else { 111 throw new IllegalArgumentException("Invalid URI: " + url); 112 } 113 } 114 } 115 116 /** 117 * Get the content URI of a row added to a table. 118 * @param tableUri of the entire table 119 * @param values found in the row 120 * @param rowId of the row 121 * @return the content URI for this particular row 122 */ 123 private Uri getUriFor(Uri tableUri, ContentValues values, long rowId) { 124 if (tableUri.getPathSegments().size() != 1) { 125 throw new IllegalArgumentException("Invalid URI: " + tableUri); 126 } 127 String table = tableUri.getPathSegments().get(0); 128 if ("system".equals(table) || "secure".equals(table)) { 129 String name = values.getAsString(Settings.NameValueTable.NAME); 130 return Uri.withAppendedPath(tableUri, name); 131 } else { 132 return ContentUris.withAppendedId(tableUri, rowId); 133 } 134 } 135 136 /** 137 * Send a notification when a particular content URI changes. 138 * Modify the system property used to communicate the version of 139 * this table, for tables which have such a property. (The Settings 140 * contract class uses these to provide client-side caches.) 141 * @param uri to send notifications for 142 */ 143 private void sendNotify(Uri uri) { 144 // Update the system property *first*, so if someone is listening for 145 // a notification and then using the contract class to get their data, 146 // the system property will be updated and they'll get the new data. 147 148 boolean backedUpDataChanged = false; 149 String property = null, table = uri.getPathSegments().get(0); 150 if (table.equals("system")) { 151 property = Settings.System.SYS_PROP_SETTING_VERSION; 152 backedUpDataChanged = true; 153 } else if (table.equals("secure")) { 154 property = Settings.Secure.SYS_PROP_SETTING_VERSION; 155 backedUpDataChanged = true; 156 } 157 158 if (property != null) { 159 long version = SystemProperties.getLong(property, 0) + 1; 160 if (LOCAL_LOGV) Log.v(TAG, "property: " + property + "=" + version); 161 SystemProperties.set(property, Long.toString(version)); 162 } 163 164 // Inform the backup manager about a data change 165 if (backedUpDataChanged) { 166 mBackupManager.dataChanged(); 167 } 168 // Now send the notification through the content framework. 169 170 String notify = uri.getQueryParameter("notify"); 171 if (notify == null || "true".equals(notify)) { 172 getContext().getContentResolver().notifyChange(uri, null); 173 if (LOCAL_LOGV) Log.v(TAG, "notifying: " + uri); 174 } else { 175 if (LOCAL_LOGV) Log.v(TAG, "notification suppressed: " + uri); 176 } 177 } 178 179 /** 180 * Make sure the caller has permission to write this data. 181 * @param args supplied by the caller 182 * @throws SecurityException if the caller is forbidden to write. 183 */ 184 private void checkWritePermissions(SqlArguments args) { 185 if ("secure".equals(args.table) && 186 getContext().checkCallingOrSelfPermission( 187 android.Manifest.permission.WRITE_SECURE_SETTINGS) != 188 PackageManager.PERMISSION_GRANTED) { 189 throw new SecurityException( 190 String.format("Permission denial: writing to secure settings requires %1$s", 191 android.Manifest.permission.WRITE_SECURE_SETTINGS)); 192 } 193 } 194 195 @Override 196 public boolean onCreate() { 197 mOpenHelper = new DatabaseHelper(getContext()); 198 mBackupManager = new BackupManager(getContext()); 199 200 if (!ensureAndroidIdIsSet()) { 201 return false; 202 } 203 204 return true; 205 } 206 207 private boolean ensureAndroidIdIsSet() { 208 final Cursor c = query(Settings.Secure.CONTENT_URI, 209 new String[] { Settings.NameValueTable.VALUE }, 210 Settings.NameValueTable.NAME + "=?", 211 new String[] { Settings.Secure.ANDROID_ID }, null); 212 try { 213 final String value = c.moveToNext() ? c.getString(0) : null; 214 if (value == null) { 215 final SecureRandom random = SecureRandom.getInstance("SHA1PRNG"); 216 String serial = SystemProperties.get("ro.serialno"); 217 if (serial != null) { 218 try { 219 random.setSeed(serial.getBytes("UTF-8")); 220 } catch (UnsupportedEncodingException ignore) { 221 // stick with default seed 222 } 223 } 224 final String newAndroidIdValue = Long.toHexString(random.nextLong()); 225 Log.d(TAG, "Generated and saved new ANDROID_ID"); 226 final ContentValues values = new ContentValues(); 227 values.put(Settings.NameValueTable.NAME, Settings.Secure.ANDROID_ID); 228 values.put(Settings.NameValueTable.VALUE, newAndroidIdValue); 229 final Uri uri = insert(Settings.Secure.CONTENT_URI, values); 230 if (uri == null) { 231 return false; 232 } 233 } 234 return true; 235 } catch (NoSuchAlgorithmException e) { 236 return false; 237 } finally { 238 c.close(); 239 } 240 } 241 242 /** 243 * Fast path that avoids the use of chatty remoted Cursors. 244 */ 245 @Override 246 public Bundle call(String method, String request, Bundle args) { 247 if (Settings.CALL_METHOD_GET_SYSTEM.equals(method)) { 248 return lookupValue("system", sSystemCache, request); 249 } 250 if (Settings.CALL_METHOD_GET_SECURE.equals(method)) { 251 return lookupValue("secure", sSecureCache, request); 252 } 253 return null; 254 } 255 256 // Looks up value 'key' in 'table' and returns either a single-pair Bundle, 257 // possibly with a null value, or null on failure. 258 private Bundle lookupValue(String table, SettingsCache cache, String key) { 259 synchronized (cache) { 260 if (cache.containsKey(key)) { 261 return cache.get(key); 262 } 263 } 264 265 SQLiteDatabase db = mOpenHelper.getReadableDatabase(); 266 Cursor cursor = null; 267 try { 268 cursor = db.query(table, COLUMN_VALUE, "name=?", new String[]{key}, 269 null, null, null, null); 270 if (cursor != null && cursor.getCount() == 1) { 271 cursor.moveToFirst(); 272 return cache.putIfAbsent(key, cursor.getString(0)); 273 } 274 } catch (SQLiteException e) { 275 Log.w(TAG, "settings lookup error", e); 276 return null; 277 } finally { 278 if (cursor != null) cursor.close(); 279 } 280 cache.putIfAbsent(key, null); 281 return NULL_SETTING; 282 } 283 284 @Override 285 public Cursor query(Uri url, String[] select, String where, String[] whereArgs, String sort) { 286 SqlArguments args = new SqlArguments(url, where, whereArgs); 287 SQLiteDatabase db = mOpenHelper.getReadableDatabase(); 288 289 // The favorites table was moved from this provider to a provider inside Home 290 // Home still need to query this table to upgrade from pre-cupcake builds 291 // However, a cupcake+ build with no data does not contain this table which will 292 // cause an exception in the SQL stack. The following line is a special case to 293 // let the caller of the query have a chance to recover and avoid the exception 294 if (TABLE_FAVORITES.equals(args.table)) { 295 return null; 296 } else if (TABLE_OLD_FAVORITES.equals(args.table)) { 297 args.table = TABLE_FAVORITES; 298 Cursor cursor = db.rawQuery("PRAGMA table_info(favorites);", null); 299 if (cursor != null) { 300 boolean exists = cursor.getCount() > 0; 301 cursor.close(); 302 if (!exists) return null; 303 } else { 304 return null; 305 } 306 } 307 308 SQLiteQueryBuilder qb = new SQLiteQueryBuilder(); 309 qb.setTables(args.table); 310 311 Cursor ret = qb.query(db, select, args.where, args.args, null, null, sort); 312 ret.setNotificationUri(getContext().getContentResolver(), url); 313 return ret; 314 } 315 316 @Override 317 public String getType(Uri url) { 318 // If SqlArguments supplies a where clause, then it must be an item 319 // (because we aren't supplying our own where clause). 320 SqlArguments args = new SqlArguments(url, null, null); 321 if (TextUtils.isEmpty(args.where)) { 322 return "vnd.android.cursor.dir/" + args.table; 323 } else { 324 return "vnd.android.cursor.item/" + args.table; 325 } 326 } 327 328 @Override 329 public int bulkInsert(Uri uri, ContentValues[] values) { 330 SqlArguments args = new SqlArguments(uri); 331 if (TABLE_FAVORITES.equals(args.table)) { 332 return 0; 333 } 334 checkWritePermissions(args); 335 SettingsCache cache = SettingsCache.forTable(args.table); 336 337 SQLiteDatabase db = mOpenHelper.getWritableDatabase(); 338 db.beginTransaction(); 339 try { 340 int numValues = values.length; 341 for (int i = 0; i < numValues; i++) { 342 if (db.insert(args.table, null, values[i]) < 0) return 0; 343 SettingsCache.populate(cache, values[i]); 344 if (LOCAL_LOGV) Log.v(TAG, args.table + " <- " + values[i]); 345 } 346 db.setTransactionSuccessful(); 347 } finally { 348 db.endTransaction(); 349 } 350 351 sendNotify(uri); 352 return values.length; 353 } 354 355 /* 356 * Used to parse changes to the value of Settings.Secure.LOCATION_PROVIDERS_ALLOWED. 357 * This setting contains a list of the currently enabled location providers. 358 * But helper functions in android.providers.Settings can enable or disable 359 * a single provider by using a "+" or "-" prefix before the provider name. 360 * 361 * @returns whether the database needs to be updated or not, also modifying 362 * 'initialValues' if needed. 363 */ 364 private boolean parseProviderList(Uri url, ContentValues initialValues) { 365 String value = initialValues.getAsString(Settings.Secure.VALUE); 366 String newProviders = null; 367 if (value != null && value.length() > 1) { 368 char prefix = value.charAt(0); 369 if (prefix == '+' || prefix == '-') { 370 // skip prefix 371 value = value.substring(1); 372 373 // read list of enabled providers into "providers" 374 String providers = ""; 375 String[] columns = {Settings.Secure.VALUE}; 376 String where = Settings.Secure.NAME + "=\'" + Settings.Secure.LOCATION_PROVIDERS_ALLOWED + "\'"; 377 Cursor cursor = query(url, columns, where, null, null); 378 if (cursor != null && cursor.getCount() == 1) { 379 try { 380 cursor.moveToFirst(); 381 providers = cursor.getString(0); 382 } finally { 383 cursor.close(); 384 } 385 } 386 387 int index = providers.indexOf(value); 388 int end = index + value.length(); 389 // check for commas to avoid matching on partial string 390 if (index > 0 && providers.charAt(index - 1) != ',') index = -1; 391 if (end < providers.length() && providers.charAt(end) != ',') index = -1; 392 393 if (prefix == '+' && index < 0) { 394 // append the provider to the list if not present 395 if (providers.length() == 0) { 396 newProviders = value; 397 } else { 398 newProviders = providers + ',' + value; 399 } 400 } else if (prefix == '-' && index >= 0) { 401 // remove the provider from the list if present 402 // remove leading and trailing commas 403 if (index > 0) index--; 404 if (end < providers.length()) end++; 405 406 newProviders = providers.substring(0, index); 407 if (end < providers.length()) { 408 newProviders += providers.substring(end); 409 } 410 } else { 411 // nothing changed, so no need to update the database 412 return false; 413 } 414 415 if (newProviders != null) { 416 initialValues.put(Settings.Secure.VALUE, newProviders); 417 } 418 } 419 } 420 421 return true; 422 } 423 424 @Override 425 public Uri insert(Uri url, ContentValues initialValues) { 426 SqlArguments args = new SqlArguments(url); 427 if (TABLE_FAVORITES.equals(args.table)) { 428 return null; 429 } 430 checkWritePermissions(args); 431 432 // Special case LOCATION_PROVIDERS_ALLOWED. 433 // Support enabling/disabling a single provider (using "+" or "-" prefix) 434 String name = initialValues.getAsString(Settings.Secure.NAME); 435 if (Settings.Secure.LOCATION_PROVIDERS_ALLOWED.equals(name)) { 436 if (!parseProviderList(url, initialValues)) return null; 437 } 438 439 SettingsCache cache = SettingsCache.forTable(args.table); 440 String value = initialValues.getAsString(Settings.NameValueTable.VALUE); 441 if (SettingsCache.isRedundantSetValue(cache, name, value)) { 442 return Uri.withAppendedPath(url, name); 443 } 444 445 SQLiteDatabase db = mOpenHelper.getWritableDatabase(); 446 final long rowId = db.insert(args.table, null, initialValues); 447 if (rowId <= 0) return null; 448 449 SettingsCache.populate(cache, initialValues); // before we notify 450 451 if (LOCAL_LOGV) Log.v(TAG, args.table + " <- " + initialValues); 452 url = getUriFor(url, initialValues, rowId); 453 sendNotify(url); 454 return url; 455 } 456 457 @Override 458 public int delete(Uri url, String where, String[] whereArgs) { 459 SqlArguments args = new SqlArguments(url, where, whereArgs); 460 if (TABLE_FAVORITES.equals(args.table)) { 461 return 0; 462 } else if (TABLE_OLD_FAVORITES.equals(args.table)) { 463 args.table = TABLE_FAVORITES; 464 } 465 checkWritePermissions(args); 466 467 SQLiteDatabase db = mOpenHelper.getWritableDatabase(); 468 int count = db.delete(args.table, args.where, args.args); 469 if (count > 0) { 470 SettingsCache.wipe(args.table); // before we notify 471 sendNotify(url); 472 } 473 if (LOCAL_LOGV) Log.v(TAG, args.table + ": " + count + " row(s) deleted"); 474 return count; 475 } 476 477 @Override 478 public int update(Uri url, ContentValues initialValues, String where, String[] whereArgs) { 479 SqlArguments args = new SqlArguments(url, where, whereArgs); 480 if (TABLE_FAVORITES.equals(args.table)) { 481 return 0; 482 } 483 checkWritePermissions(args); 484 485 SQLiteDatabase db = mOpenHelper.getWritableDatabase(); 486 int count = db.update(args.table, initialValues, args.where, args.args); 487 if (count > 0) { 488 SettingsCache.wipe(args.table); // before we notify 489 sendNotify(url); 490 } 491 if (LOCAL_LOGV) Log.v(TAG, args.table + ": " + count + " row(s) <- " + initialValues); 492 return count; 493 } 494 495 @Override 496 public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException { 497 498 /* 499 * When a client attempts to openFile the default ringtone or 500 * notification setting Uri, we will proxy the call to the current 501 * default ringtone's Uri (if it is in the DRM or media provider). 502 */ 503 int ringtoneType = RingtoneManager.getDefaultType(uri); 504 // Above call returns -1 if the Uri doesn't match a default type 505 if (ringtoneType != -1) { 506 Context context = getContext(); 507 508 // Get the current value for the default sound 509 Uri soundUri = RingtoneManager.getActualDefaultRingtoneUri(context, ringtoneType); 510 511 if (soundUri != null) { 512 // Only proxy the openFile call to drm or media providers 513 String authority = soundUri.getAuthority(); 514 boolean isDrmAuthority = authority.equals(DrmStore.AUTHORITY); 515 if (isDrmAuthority || authority.equals(MediaStore.AUTHORITY)) { 516 517 if (isDrmAuthority) { 518 try { 519 // Check DRM access permission here, since once we 520 // do the below call the DRM will be checking our 521 // permission, not our caller's permission 522 DrmStore.enforceAccessDrmPermission(context); 523 } catch (SecurityException e) { 524 throw new FileNotFoundException(e.getMessage()); 525 } 526 } 527 528 return context.getContentResolver().openFileDescriptor(soundUri, mode); 529 } 530 } 531 } 532 533 return super.openFile(uri, mode); 534 } 535 536 @Override 537 public AssetFileDescriptor openAssetFile(Uri uri, String mode) throws FileNotFoundException { 538 539 /* 540 * When a client attempts to openFile the default ringtone or 541 * notification setting Uri, we will proxy the call to the current 542 * default ringtone's Uri (if it is in the DRM or media provider). 543 */ 544 int ringtoneType = RingtoneManager.getDefaultType(uri); 545 // Above call returns -1 if the Uri doesn't match a default type 546 if (ringtoneType != -1) { 547 Context context = getContext(); 548 549 // Get the current value for the default sound 550 Uri soundUri = RingtoneManager.getActualDefaultRingtoneUri(context, ringtoneType); 551 552 if (soundUri != null) { 553 // Only proxy the openFile call to drm or media providers 554 String authority = soundUri.getAuthority(); 555 boolean isDrmAuthority = authority.equals(DrmStore.AUTHORITY); 556 if (isDrmAuthority || authority.equals(MediaStore.AUTHORITY)) { 557 558 if (isDrmAuthority) { 559 try { 560 // Check DRM access permission here, since once we 561 // do the below call the DRM will be checking our 562 // permission, not our caller's permission 563 DrmStore.enforceAccessDrmPermission(context); 564 } catch (SecurityException e) { 565 throw new FileNotFoundException(e.getMessage()); 566 } 567 } 568 569 ParcelFileDescriptor pfd = null; 570 try { 571 pfd = context.getContentResolver().openFileDescriptor(soundUri, mode); 572 return new AssetFileDescriptor(pfd, 0, -1); 573 } catch (FileNotFoundException ex) { 574 // fall through and open the fallback ringtone below 575 } 576 } 577 578 try { 579 return super.openAssetFile(soundUri, mode); 580 } catch (FileNotFoundException ex) { 581 // Since a non-null Uri was specified, but couldn't be opened, 582 // fall back to the built-in ringtone. 583 return context.getResources().openRawResourceFd( 584 com.android.internal.R.raw.fallbackring); 585 } 586 } 587 // no need to fall through and have openFile() try again, since we 588 // already know that will fail. 589 throw new FileNotFoundException(); // or return null ? 590 } 591 592 // Note that this will end up calling openFile() above. 593 return super.openAssetFile(uri, mode); 594 } 595 596 /** 597 * In-memory LRU Cache of system and secure settings, along with 598 * associated helper functions to keep cache coherent with the 599 * database. 600 */ 601 private static final class SettingsCache extends LinkedHashMap<String, Bundle> { 602 603 public SettingsCache() { 604 super(MAX_CACHE_ENTRIES, 0.75f /* load factor */, true /* access ordered */); 605 } 606 607 @Override 608 protected boolean removeEldestEntry(Map.Entry eldest) { 609 return size() > MAX_CACHE_ENTRIES; 610 } 611 612 /** 613 * Atomic cache population, conditional on size of value and if 614 * we lost a race. 615 * 616 * @returns a Bundle to send back to the client from call(), even 617 * if we lost the race. 618 */ 619 public Bundle putIfAbsent(String key, String value) { 620 Bundle bundle = (value == null) ? NULL_SETTING : Bundle.forPair("value", value); 621 if (value == null || value.length() <= MAX_CACHE_ENTRY_SIZE) { 622 synchronized (this) { 623 if (!containsKey(key)) { 624 put(key, bundle); 625 } 626 } 627 } 628 return bundle; 629 } 630 631 public static SettingsCache forTable(String tableName) { 632 if ("system".equals(tableName)) { 633 return SettingsProvider.sSystemCache; 634 } 635 if ("secure".equals(tableName)) { 636 return SettingsProvider.sSecureCache; 637 } 638 return null; 639 } 640 641 /** 642 * Populates a key in a given (possibly-null) cache. 643 */ 644 public static void populate(SettingsCache cache, ContentValues contentValues) { 645 if (cache == null) { 646 return; 647 } 648 String name = contentValues.getAsString(Settings.NameValueTable.NAME); 649 if (name == null) { 650 Log.w(TAG, "null name populating settings cache."); 651 return; 652 } 653 String value = contentValues.getAsString(Settings.NameValueTable.VALUE); 654 synchronized (cache) { 655 if (value == null || value.length() <= MAX_CACHE_ENTRY_SIZE) { 656 cache.put(name, Bundle.forPair(Settings.NameValueTable.VALUE, value)); 657 } else { 658 cache.remove(name); 659 } 660 } 661 } 662 663 /** 664 * Used for wiping a whole cache on deletes when we're not 665 * sure what exactly was deleted or changed. 666 */ 667 public static void wipe(String tableName) { 668 SettingsCache cache = SettingsCache.forTable(tableName); 669 if (cache == null) { 670 return; 671 } 672 synchronized (cache) { 673 cache.clear(); 674 } 675 } 676 677 /** 678 * For suppressing duplicate/redundant settings inserts early, 679 * checking our cache first (but without faulting it in), 680 * before going to sqlite with the mutation. 681 */ 682 public static boolean isRedundantSetValue(SettingsCache cache, String name, String value) { 683 if (cache == null) return false; 684 synchronized (cache) { 685 Bundle bundle = cache.get(name); 686 if (bundle == null) return false; 687 String oldValue = bundle.getPairValue(); 688 if (oldValue == null && value == null) return true; 689 if ((oldValue == null) != (value == null)) return false; 690 return oldValue.equals(value); 691 } 692 } 693 } 694} 695