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