1/*
2 * Copyright (C) 2010 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 android.app;
18
19import android.annotation.Nullable;
20import android.content.SharedPreferences;
21import android.os.FileUtils;
22import android.os.Looper;
23import android.system.ErrnoException;
24import android.system.Os;
25import android.system.StructStat;
26import android.util.Log;
27
28import com.google.android.collect.Maps;
29import com.android.internal.util.XmlUtils;
30
31import dalvik.system.BlockGuard;
32
33import org.xmlpull.v1.XmlPullParserException;
34
35import java.io.BufferedInputStream;
36import java.io.File;
37import java.io.FileInputStream;
38import java.io.FileNotFoundException;
39import java.io.FileOutputStream;
40import java.io.IOException;
41import java.util.ArrayList;
42import java.util.HashMap;
43import java.util.HashSet;
44import java.util.List;
45import java.util.Map;
46import java.util.Set;
47import java.util.WeakHashMap;
48import java.util.concurrent.CountDownLatch;
49
50import libcore.io.IoUtils;
51
52final class SharedPreferencesImpl implements SharedPreferences {
53    private static final String TAG = "SharedPreferencesImpl";
54    private static final boolean DEBUG = false;
55
56    // Lock ordering rules:
57    //  - acquire SharedPreferencesImpl.this before EditorImpl.this
58    //  - acquire mWritingToDiskLock before EditorImpl.this
59
60    private final File mFile;
61    private final File mBackupFile;
62    private final int mMode;
63
64    private Map<String, Object> mMap;     // guarded by 'this'
65    private int mDiskWritesInFlight = 0;  // guarded by 'this'
66    private boolean mLoaded = false;      // guarded by 'this'
67    private long mStatTimestamp;          // guarded by 'this'
68    private long mStatSize;               // guarded by 'this'
69
70    private final Object mWritingToDiskLock = new Object();
71    private static final Object mContent = new Object();
72    private final WeakHashMap<OnSharedPreferenceChangeListener, Object> mListeners =
73            new WeakHashMap<OnSharedPreferenceChangeListener, Object>();
74
75    SharedPreferencesImpl(File file, int mode) {
76        mFile = file;
77        mBackupFile = makeBackupFile(file);
78        mMode = mode;
79        mLoaded = false;
80        mMap = null;
81        startLoadFromDisk();
82    }
83
84    private void startLoadFromDisk() {
85        synchronized (this) {
86            mLoaded = false;
87        }
88        new Thread("SharedPreferencesImpl-load") {
89            public void run() {
90                synchronized (SharedPreferencesImpl.this) {
91                    loadFromDiskLocked();
92                }
93            }
94        }.start();
95    }
96
97    private void loadFromDiskLocked() {
98        if (mLoaded) {
99            return;
100        }
101        if (mBackupFile.exists()) {
102            mFile.delete();
103            mBackupFile.renameTo(mFile);
104        }
105
106        // Debugging
107        if (mFile.exists() && !mFile.canRead()) {
108            Log.w(TAG, "Attempt to read preferences file " + mFile + " without permission");
109        }
110
111        Map map = null;
112        StructStat stat = null;
113        try {
114            stat = Os.stat(mFile.getPath());
115            if (mFile.canRead()) {
116                BufferedInputStream str = null;
117                try {
118                    str = new BufferedInputStream(
119                            new FileInputStream(mFile), 16*1024);
120                    map = XmlUtils.readMapXml(str);
121                } catch (XmlPullParserException e) {
122                    Log.w(TAG, "getSharedPreferences", e);
123                } catch (FileNotFoundException e) {
124                    Log.w(TAG, "getSharedPreferences", e);
125                } catch (IOException e) {
126                    Log.w(TAG, "getSharedPreferences", e);
127                } finally {
128                    IoUtils.closeQuietly(str);
129                }
130            }
131        } catch (ErrnoException e) {
132        }
133        mLoaded = true;
134        if (map != null) {
135            mMap = map;
136            mStatTimestamp = stat.st_mtime;
137            mStatSize = stat.st_size;
138        } else {
139            mMap = new HashMap<String, Object>();
140        }
141        notifyAll();
142    }
143
144    private static File makeBackupFile(File prefsFile) {
145        return new File(prefsFile.getPath() + ".bak");
146    }
147
148    void startReloadIfChangedUnexpectedly() {
149        synchronized (this) {
150            // TODO: wait for any pending writes to disk?
151            if (!hasFileChangedUnexpectedly()) {
152                return;
153            }
154            startLoadFromDisk();
155        }
156    }
157
158    // Has the file changed out from under us?  i.e. writes that
159    // we didn't instigate.
160    private boolean hasFileChangedUnexpectedly() {
161        synchronized (this) {
162            if (mDiskWritesInFlight > 0) {
163                // If we know we caused it, it's not unexpected.
164                if (DEBUG) Log.d(TAG, "disk write in flight, not unexpected.");
165                return false;
166            }
167        }
168
169        final StructStat stat;
170        try {
171            /*
172             * Metadata operations don't usually count as a block guard
173             * violation, but we explicitly want this one.
174             */
175            BlockGuard.getThreadPolicy().onReadFromDisk();
176            stat = Os.stat(mFile.getPath());
177        } catch (ErrnoException e) {
178            return true;
179        }
180
181        synchronized (this) {
182            return mStatTimestamp != stat.st_mtime || mStatSize != stat.st_size;
183        }
184    }
185
186    public void registerOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {
187        synchronized(this) {
188            mListeners.put(listener, mContent);
189        }
190    }
191
192    public void unregisterOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {
193        synchronized(this) {
194            mListeners.remove(listener);
195        }
196    }
197
198    private void awaitLoadedLocked() {
199        if (!mLoaded) {
200            // Raise an explicit StrictMode onReadFromDisk for this
201            // thread, since the real read will be in a different
202            // thread and otherwise ignored by StrictMode.
203            BlockGuard.getThreadPolicy().onReadFromDisk();
204        }
205        while (!mLoaded) {
206            try {
207                wait();
208            } catch (InterruptedException unused) {
209            }
210        }
211    }
212
213    public Map<String, ?> getAll() {
214        synchronized (this) {
215            awaitLoadedLocked();
216            //noinspection unchecked
217            return new HashMap<String, Object>(mMap);
218        }
219    }
220
221    @Nullable
222    public String getString(String key, @Nullable String defValue) {
223        synchronized (this) {
224            awaitLoadedLocked();
225            String v = (String)mMap.get(key);
226            return v != null ? v : defValue;
227        }
228    }
229
230    @Nullable
231    public Set<String> getStringSet(String key, @Nullable Set<String> defValues) {
232        synchronized (this) {
233            awaitLoadedLocked();
234            Set<String> v = (Set<String>) mMap.get(key);
235            return v != null ? v : defValues;
236        }
237    }
238
239    public int getInt(String key, int defValue) {
240        synchronized (this) {
241            awaitLoadedLocked();
242            Integer v = (Integer)mMap.get(key);
243            return v != null ? v : defValue;
244        }
245    }
246    public long getLong(String key, long defValue) {
247        synchronized (this) {
248            awaitLoadedLocked();
249            Long v = (Long)mMap.get(key);
250            return v != null ? v : defValue;
251        }
252    }
253    public float getFloat(String key, float defValue) {
254        synchronized (this) {
255            awaitLoadedLocked();
256            Float v = (Float)mMap.get(key);
257            return v != null ? v : defValue;
258        }
259    }
260    public boolean getBoolean(String key, boolean defValue) {
261        synchronized (this) {
262            awaitLoadedLocked();
263            Boolean v = (Boolean)mMap.get(key);
264            return v != null ? v : defValue;
265        }
266    }
267
268    public boolean contains(String key) {
269        synchronized (this) {
270            awaitLoadedLocked();
271            return mMap.containsKey(key);
272        }
273    }
274
275    public Editor edit() {
276        // TODO: remove the need to call awaitLoadedLocked() when
277        // requesting an editor.  will require some work on the
278        // Editor, but then we should be able to do:
279        //
280        //      context.getSharedPreferences(..).edit().putString(..).apply()
281        //
282        // ... all without blocking.
283        synchronized (this) {
284            awaitLoadedLocked();
285        }
286
287        return new EditorImpl();
288    }
289
290    // Return value from EditorImpl#commitToMemory()
291    private static class MemoryCommitResult {
292        public boolean changesMade;  // any keys different?
293        public List<String> keysModified;  // may be null
294        public Set<OnSharedPreferenceChangeListener> listeners;  // may be null
295        public Map<?, ?> mapToWriteToDisk;
296        public final CountDownLatch writtenToDiskLatch = new CountDownLatch(1);
297        public volatile boolean writeToDiskResult = false;
298
299        public void setDiskWriteResult(boolean result) {
300            writeToDiskResult = result;
301            writtenToDiskLatch.countDown();
302        }
303    }
304
305    public final class EditorImpl implements Editor {
306        private final Map<String, Object> mModified = Maps.newHashMap();
307        private boolean mClear = false;
308
309        public Editor putString(String key, @Nullable String value) {
310            synchronized (this) {
311                mModified.put(key, value);
312                return this;
313            }
314        }
315        public Editor putStringSet(String key, @Nullable Set<String> values) {
316            synchronized (this) {
317                mModified.put(key,
318                        (values == null) ? null : new HashSet<String>(values));
319                return this;
320            }
321        }
322        public Editor putInt(String key, int value) {
323            synchronized (this) {
324                mModified.put(key, value);
325                return this;
326            }
327        }
328        public Editor putLong(String key, long value) {
329            synchronized (this) {
330                mModified.put(key, value);
331                return this;
332            }
333        }
334        public Editor putFloat(String key, float value) {
335            synchronized (this) {
336                mModified.put(key, value);
337                return this;
338            }
339        }
340        public Editor putBoolean(String key, boolean value) {
341            synchronized (this) {
342                mModified.put(key, value);
343                return this;
344            }
345        }
346
347        public Editor remove(String key) {
348            synchronized (this) {
349                mModified.put(key, this);
350                return this;
351            }
352        }
353
354        public Editor clear() {
355            synchronized (this) {
356                mClear = true;
357                return this;
358            }
359        }
360
361        public void apply() {
362            final MemoryCommitResult mcr = commitToMemory();
363            final Runnable awaitCommit = new Runnable() {
364                    public void run() {
365                        try {
366                            mcr.writtenToDiskLatch.await();
367                        } catch (InterruptedException ignored) {
368                        }
369                    }
370                };
371
372            QueuedWork.add(awaitCommit);
373
374            Runnable postWriteRunnable = new Runnable() {
375                    public void run() {
376                        awaitCommit.run();
377                        QueuedWork.remove(awaitCommit);
378                    }
379                };
380
381            SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);
382
383            // Okay to notify the listeners before it's hit disk
384            // because the listeners should always get the same
385            // SharedPreferences instance back, which has the
386            // changes reflected in memory.
387            notifyListeners(mcr);
388        }
389
390        // Returns true if any changes were made
391        private MemoryCommitResult commitToMemory() {
392            MemoryCommitResult mcr = new MemoryCommitResult();
393            synchronized (SharedPreferencesImpl.this) {
394                // We optimistically don't make a deep copy until
395                // a memory commit comes in when we're already
396                // writing to disk.
397                if (mDiskWritesInFlight > 0) {
398                    // We can't modify our mMap as a currently
399                    // in-flight write owns it.  Clone it before
400                    // modifying it.
401                    // noinspection unchecked
402                    mMap = new HashMap<String, Object>(mMap);
403                }
404                mcr.mapToWriteToDisk = mMap;
405                mDiskWritesInFlight++;
406
407                boolean hasListeners = mListeners.size() > 0;
408                if (hasListeners) {
409                    mcr.keysModified = new ArrayList<String>();
410                    mcr.listeners =
411                            new HashSet<OnSharedPreferenceChangeListener>(mListeners.keySet());
412                }
413
414                synchronized (this) {
415                    if (mClear) {
416                        if (!mMap.isEmpty()) {
417                            mcr.changesMade = true;
418                            mMap.clear();
419                        }
420                        mClear = false;
421                    }
422
423                    for (Map.Entry<String, Object> e : mModified.entrySet()) {
424                        String k = e.getKey();
425                        Object v = e.getValue();
426                        // "this" is the magic value for a removal mutation. In addition,
427                        // setting a value to "null" for a given key is specified to be
428                        // equivalent to calling remove on that key.
429                        if (v == this || v == null) {
430                            if (!mMap.containsKey(k)) {
431                                continue;
432                            }
433                            mMap.remove(k);
434                        } else {
435                            if (mMap.containsKey(k)) {
436                                Object existingValue = mMap.get(k);
437                                if (existingValue != null && existingValue.equals(v)) {
438                                    continue;
439                                }
440                            }
441                            mMap.put(k, v);
442                        }
443
444                        mcr.changesMade = true;
445                        if (hasListeners) {
446                            mcr.keysModified.add(k);
447                        }
448                    }
449
450                    mModified.clear();
451                }
452            }
453            return mcr;
454        }
455
456        public boolean commit() {
457            MemoryCommitResult mcr = commitToMemory();
458            SharedPreferencesImpl.this.enqueueDiskWrite(
459                mcr, null /* sync write on this thread okay */);
460            try {
461                mcr.writtenToDiskLatch.await();
462            } catch (InterruptedException e) {
463                return false;
464            }
465            notifyListeners(mcr);
466            return mcr.writeToDiskResult;
467        }
468
469        private void notifyListeners(final MemoryCommitResult mcr) {
470            if (mcr.listeners == null || mcr.keysModified == null ||
471                mcr.keysModified.size() == 0) {
472                return;
473            }
474            if (Looper.myLooper() == Looper.getMainLooper()) {
475                for (int i = mcr.keysModified.size() - 1; i >= 0; i--) {
476                    final String key = mcr.keysModified.get(i);
477                    for (OnSharedPreferenceChangeListener listener : mcr.listeners) {
478                        if (listener != null) {
479                            listener.onSharedPreferenceChanged(SharedPreferencesImpl.this, key);
480                        }
481                    }
482                }
483            } else {
484                // Run this function on the main thread.
485                ActivityThread.sMainThreadHandler.post(new Runnable() {
486                        public void run() {
487                            notifyListeners(mcr);
488                        }
489                    });
490            }
491        }
492    }
493
494    /**
495     * Enqueue an already-committed-to-memory result to be written
496     * to disk.
497     *
498     * They will be written to disk one-at-a-time in the order
499     * that they're enqueued.
500     *
501     * @param postWriteRunnable if non-null, we're being called
502     *   from apply() and this is the runnable to run after
503     *   the write proceeds.  if null (from a regular commit()),
504     *   then we're allowed to do this disk write on the main
505     *   thread (which in addition to reducing allocations and
506     *   creating a background thread, this has the advantage that
507     *   we catch them in userdebug StrictMode reports to convert
508     *   them where possible to apply() ...)
509     */
510    private void enqueueDiskWrite(final MemoryCommitResult mcr,
511                                  final Runnable postWriteRunnable) {
512        final Runnable writeToDiskRunnable = new Runnable() {
513                public void run() {
514                    synchronized (mWritingToDiskLock) {
515                        writeToFile(mcr);
516                    }
517                    synchronized (SharedPreferencesImpl.this) {
518                        mDiskWritesInFlight--;
519                    }
520                    if (postWriteRunnable != null) {
521                        postWriteRunnable.run();
522                    }
523                }
524            };
525
526        final boolean isFromSyncCommit = (postWriteRunnable == null);
527
528        // Typical #commit() path with fewer allocations, doing a write on
529        // the current thread.
530        if (isFromSyncCommit) {
531            boolean wasEmpty = false;
532            synchronized (SharedPreferencesImpl.this) {
533                wasEmpty = mDiskWritesInFlight == 1;
534            }
535            if (wasEmpty) {
536                writeToDiskRunnable.run();
537                return;
538            }
539        }
540
541        QueuedWork.singleThreadExecutor().execute(writeToDiskRunnable);
542    }
543
544    private static FileOutputStream createFileOutputStream(File file) {
545        FileOutputStream str = null;
546        try {
547            str = new FileOutputStream(file);
548        } catch (FileNotFoundException e) {
549            File parent = file.getParentFile();
550            if (!parent.mkdir()) {
551                Log.e(TAG, "Couldn't create directory for SharedPreferences file " + file);
552                return null;
553            }
554            FileUtils.setPermissions(
555                parent.getPath(),
556                FileUtils.S_IRWXU|FileUtils.S_IRWXG|FileUtils.S_IXOTH,
557                -1, -1);
558            try {
559                str = new FileOutputStream(file);
560            } catch (FileNotFoundException e2) {
561                Log.e(TAG, "Couldn't create SharedPreferences file " + file, e2);
562            }
563        }
564        return str;
565    }
566
567    // Note: must hold mWritingToDiskLock
568    private void writeToFile(MemoryCommitResult mcr) {
569        // Rename the current file so it may be used as a backup during the next read
570        if (mFile.exists()) {
571            if (!mcr.changesMade) {
572                // If the file already exists, but no changes were
573                // made to the underlying map, it's wasteful to
574                // re-write the file.  Return as if we wrote it
575                // out.
576                mcr.setDiskWriteResult(true);
577                return;
578            }
579            if (!mBackupFile.exists()) {
580                if (!mFile.renameTo(mBackupFile)) {
581                    Log.e(TAG, "Couldn't rename file " + mFile
582                          + " to backup file " + mBackupFile);
583                    mcr.setDiskWriteResult(false);
584                    return;
585                }
586            } else {
587                mFile.delete();
588            }
589        }
590
591        // Attempt to write the file, delete the backup and return true as atomically as
592        // possible.  If any exception occurs, delete the new file; next time we will restore
593        // from the backup.
594        try {
595            FileOutputStream str = createFileOutputStream(mFile);
596            if (str == null) {
597                mcr.setDiskWriteResult(false);
598                return;
599            }
600            XmlUtils.writeMapXml(mcr.mapToWriteToDisk, str);
601            FileUtils.sync(str);
602            str.close();
603            ContextImpl.setFilePermissionsFromMode(mFile.getPath(), mMode, 0);
604            try {
605                final StructStat stat = Os.stat(mFile.getPath());
606                synchronized (this) {
607                    mStatTimestamp = stat.st_mtime;
608                    mStatSize = stat.st_size;
609                }
610            } catch (ErrnoException e) {
611                // Do nothing
612            }
613            // Writing was successful, delete the backup file if there is one.
614            mBackupFile.delete();
615            mcr.setDiskWriteResult(true);
616            return;
617        } catch (XmlPullParserException e) {
618            Log.w(TAG, "writeToFile: Got exception:", e);
619        } catch (IOException e) {
620            Log.w(TAG, "writeToFile: Got exception:", e);
621        }
622        // Clean up an unsuccessfully written file
623        if (mFile.exists()) {
624            if (!mFile.delete()) {
625                Log.e(TAG, "Couldn't clean up partially-written file " + mFile);
626            }
627        }
628        mcr.setDiskWriteResult(false);
629    }
630}
631