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