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