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