Merge "Preferences backup & restore."
diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index 1cddc54..e6f05e3 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -12,7 +12,14 @@
         android:name=".DocumentsApplication"
         android:label="@string/app_label"
         android:icon="@drawable/app_icon"
-        android:supportsRtl="true">
+        android:supportsRtl="true"
+        android:allowBackup="true"
+        android:backupAgent=".prefs.BackupAgent"
+        android:fullBackupOnly="false">
+
+        <meta-data
+            android:name="com.google.android.backup.api_key"
+            android:value="AEdPqrEAAAAInBA8ued0O_ZyYUsVhwinUF-x50NIe9K0GzBW4A" />
 
         <activity
             android:name=".picker.PickActivity"
diff --git a/src/com/android/documentsui/BaseActivity.java b/src/com/android/documentsui/BaseActivity.java
index 59fbc27..2cfb0df 100644
--- a/src/com/android/documentsui/BaseActivity.java
+++ b/src/com/android/documentsui/BaseActivity.java
@@ -54,6 +54,7 @@
 import com.android.documentsui.dirlist.AnimationView;
 import com.android.documentsui.dirlist.DirectoryFragment;
 import com.android.documentsui.prefs.LocalPreferences;
+import com.android.documentsui.prefs.PreferencesMonitor;
 import com.android.documentsui.queries.DebugCommandProcessor;
 import com.android.documentsui.queries.SearchViewManager;
 import com.android.documentsui.queries.SearchViewManager.SearchManagerListener;
@@ -99,6 +100,8 @@
 
     private long mStartTime;
 
+    private PreferencesMonitor mPreferencesMonitor;
+
     public BaseActivity(@LayoutRes int layoutId, String tag) {
         mLayoutId = layoutId;
         mTag = tag;
@@ -180,6 +183,8 @@
         mSearchManager = new SearchViewManager(searchListener, dbgCommands, icicle);
         mSortController = SortController.create(this, mState.derivedMode, mState.sortModel);
 
+        mPreferencesMonitor = new PreferencesMonitor(getApplicationContext());
+
         // Base classes must update result in their onCreate.
         setResult(Activity.RESULT_CANCELED);
     }
@@ -211,6 +216,18 @@
     }
 
     @Override
+    protected void onResume() {
+        super.onResume();
+        mPreferencesMonitor.start();
+    }
+
+    @Override
+    protected void onPause() {
+        super.onPause();
+        mPreferencesMonitor.stop();
+    }
+
+    @Override
     @CallSuper
     public boolean onPrepareOptionsMenu(Menu menu) {
         super.onPrepareOptionsMenu(menu);
diff --git a/src/com/android/documentsui/prefs/BackupAgent.java b/src/com/android/documentsui/prefs/BackupAgent.java
new file mode 100644
index 0000000..65a94ca
--- /dev/null
+++ b/src/com/android/documentsui/prefs/BackupAgent.java
@@ -0,0 +1,94 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.documentsui.prefs;
+
+import android.app.backup.BackupAgentHelper;
+import android.app.backup.BackupDataInput;
+import android.app.backup.BackupDataOutput;
+import android.app.backup.SharedPreferencesBackupHelper;
+import android.content.SharedPreferences;
+import android.os.ParcelFileDescriptor;
+import android.support.annotation.VisibleForTesting;
+
+import java.io.IOException;
+
+/**
+ * Provides glue between backup infrastructure and PrefsBackupHelper (which contains the core logic
+ * for retrieving and restoring settings).
+ *
+ * When doing backup & restore, we create and add a {@link SharedPreferencesBackupHelper} for our
+ * backup preferences file in {@link #onCreate}, and populate the backup preferences file in
+ * {@link #onBackup} and {@link #onRestore}. Then {@link BackupAgentHelper#onBackup} and
+ * {@link BackupAgentHelper#onRestore} will take care of the rest of the work. See external
+ * documentation below.
+ *
+ * https://developer.android.com/guide/topics/data/keyvaluebackup.html#BackupAgentHelper
+ */
+public class BackupAgent extends BackupAgentHelper {
+
+    /**
+     * Name of the shared preferences file used by BackupAgent. Should only be used in
+     * this class.
+     *
+     * @see #onBackup(ParcelFileDescriptor, BackupDataOutput, ParcelFileDescriptor)
+     * @see #onRestore(BackupDataInput, int, ParcelFileDescriptor)
+     */
+    private static final String BACKUP_PREFS = "documentsui_backup_prefs";
+
+    /**
+     * An arbitrary string used by the BackupHelper.
+     *
+     * BackupAgentHelper works with BackupHelper. When adding a BackupHelper in #onCreate,
+     * it requires a "key". This string is that "key".
+     *
+     * https://developer.android.com/guide/topics/data/keyvaluebackup.html#BackupAgentHelper
+     * See "Backing up SharedPreference" for the purpose of this string.
+     *
+     * @see #onCreate()
+     */
+    private static final String BACKUP_HELPER_KEY = "DOCUMENTSUI_BACKUP_HELPER_KEY";
+
+    private PrefsBackupHelper mPrefsBackupHelper;
+    private SharedPreferences mBackupPreferences;
+
+    @Override
+    public void onCreate() {
+        addHelper(BACKUP_HELPER_KEY, new SharedPreferencesBackupHelper(this, BACKUP_PREFS));
+        mPrefsBackupHelper = new PrefsBackupHelper(this);
+        mBackupPreferences = getSharedPreferences(BACKUP_PREFS, MODE_PRIVATE);
+    }
+
+    @Override
+    public void onBackup(ParcelFileDescriptor oldState, BackupDataOutput data,
+            ParcelFileDescriptor newState) throws IOException {
+        mPrefsBackupHelper.getBackupPreferences(mBackupPreferences);
+        super.onBackup(oldState, data, newState);
+        mBackupPreferences.edit().clear().apply();
+    }
+
+    @Override
+    public void onRestore(BackupDataInput data, int appVersionCode, ParcelFileDescriptor newState)
+            throws IOException {
+        // TODO: refresh the UI after restore finished. Currently the restore for system apps only
+        // happens during SUW, at which time user haven't open the app. However the restore time may
+        // change in ODR. Once it happens, we may need to refresh the UI after restore finished.
+        super.onRestore(data, appVersionCode, newState);
+        mPrefsBackupHelper.putBackupPreferences(mBackupPreferences);
+        mBackupPreferences.edit().clear().apply();
+    }
+
+}
diff --git a/src/com/android/documentsui/prefs/LocalPreferences.java b/src/com/android/documentsui/prefs/LocalPreferences.java
index 1beaf5d..f5a70fc 100644
--- a/src/com/android/documentsui/prefs/LocalPreferences.java
+++ b/src/com/android/documentsui/prefs/LocalPreferences.java
@@ -123,4 +123,8 @@
                 ? userId + "|" + packageName + "||" + directory
                 : userId + "|" + packageName + "|" + uuid + "|" + directory;
     }
+
+    static boolean shouldBackup(String s) {
+        return (s != null) ? s.startsWith(ROOT_VIEW_MODE_PREFIX) : false;
+    }
 }
diff --git a/src/com/android/documentsui/prefs/PreferencesMonitor.java b/src/com/android/documentsui/prefs/PreferencesMonitor.java
new file mode 100644
index 0000000..33a4749
--- /dev/null
+++ b/src/com/android/documentsui/prefs/PreferencesMonitor.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.documentsui.prefs;
+
+import android.app.backup.BackupManager;
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.preference.PreferenceManager;
+
+/**
+ * A class that monitors changes to the default shared preferences file. If a preference which
+ * should be backed up changed, schedule a backup.
+ */
+public final class PreferencesMonitor
+        implements SharedPreferences.OnSharedPreferenceChangeListener {
+
+    private Context mContext;
+
+    public PreferencesMonitor(Context context) {
+        mContext = context;
+    }
+
+    public void start() {
+        PreferenceManager.getDefaultSharedPreferences(mContext)
+                .registerOnSharedPreferenceChangeListener(this);
+    }
+
+    public void stop() {
+        PreferenceManager.getDefaultSharedPreferences(mContext)
+                .unregisterOnSharedPreferenceChangeListener(this);
+    }
+
+    @Override
+    public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
+        if (PrefsBackupHelper.shouldBackup(key)) {
+            BackupManager.dataChanged(mContext.getPackageName());
+        }
+    }
+}
diff --git a/src/com/android/documentsui/prefs/PrefsBackupHelper.java b/src/com/android/documentsui/prefs/PrefsBackupHelper.java
new file mode 100644
index 0000000..8e7741d
--- /dev/null
+++ b/src/com/android/documentsui/prefs/PrefsBackupHelper.java
@@ -0,0 +1,90 @@
+/*
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.documentsui.prefs;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.content.SharedPreferences.Editor;
+import android.preference.PreferenceManager;
+import android.support.annotation.VisibleForTesting;
+
+import java.util.Map;
+
+/**
+ * Class providing core logic for backup and restore of DocumentsUI preferences.
+ */
+final class PrefsBackupHelper {
+
+    private SharedPreferences mDefaultPreferences;
+
+    @VisibleForTesting
+    PrefsBackupHelper(SharedPreferences overridePreferences) {
+        mDefaultPreferences = overridePreferences;
+    }
+
+    PrefsBackupHelper(Context context) {
+        mDefaultPreferences = PreferenceManager.getDefaultSharedPreferences(context);
+    }
+
+    /**
+     * Loads all applicable preferences to supplied backup file.
+     */
+    void getBackupPreferences(SharedPreferences prefs) {
+        Editor editor = prefs.edit();
+        editor.clear();
+
+        copyMatchingPreferences(mDefaultPreferences, editor);
+        editor.apply();
+    }
+
+    /**
+     * Restores all applicable preferences from the supplied preferences file.
+     */
+    void putBackupPreferences(SharedPreferences prefs) {
+        Editor editor = mDefaultPreferences.edit();
+
+        copyMatchingPreferences(prefs, editor);
+        editor.apply();
+    }
+
+    private void copyMatchingPreferences(SharedPreferences source, Editor destination) {
+        for (Map.Entry<String, ?> preference : source.getAll().entrySet()) {
+            if (shouldBackup(preference.getKey())) {
+                setPreference(destination, preference);
+            }
+        }
+    }
+
+    @SuppressWarnings("unchecked")
+    @VisibleForTesting
+    void setPreference(Editor target, final Map.Entry<String, ?> preference) {
+        final String key = preference.getKey();
+        final Object value = preference.getValue();
+        // Only handle already know types.
+        if (value instanceof Integer) {
+            target.putInt(key, (Integer) value);
+        } else if (value instanceof Boolean) {
+            target.putBoolean(key, (Boolean) value);
+        } else {
+            throw new IllegalArgumentException("DocumentsUI backup: invalid preference "
+                    + (value == null ? null : value.getClass()));
+        }
+    }
+
+    static boolean shouldBackup(String s) {
+        return LocalPreferences.shouldBackup(s) || ScopedPreferences.shouldBackup(s);
+    }
+}
diff --git a/src/com/android/documentsui/prefs/ScopedPreferences.java b/src/com/android/documentsui/prefs/ScopedPreferences.java
index c74753a..51dbb1b 100644
--- a/src/com/android/documentsui/prefs/ScopedPreferences.java
+++ b/src/com/android/documentsui/prefs/ScopedPreferences.java
@@ -28,6 +28,8 @@
  */
 public interface ScopedPreferences {
 
+    static final String INCLUDE_DEVICE_ROOT = "includeDeviceRoot-";
+
     boolean getShowDeviceRoot();
     void setShowDeviceRoot(boolean display);
 
@@ -42,8 +44,6 @@
 
     static final class RuntimeScopedPreferences implements ScopedPreferences {
 
-        private static final String INCLUDE_DEVICE_ROOT = "includeDeviceRoot-";
-
         private SharedPreferences mSharedPrefs;
         private String mScope;
 
@@ -64,4 +64,8 @@
             mSharedPrefs.edit().putBoolean(INCLUDE_DEVICE_ROOT + mScope, display).apply();
         }
     }
+
+    static boolean shouldBackup(String s) {
+        return (s != null) ? s.startsWith(INCLUDE_DEVICE_ROOT) : false;
+    }
 }
diff --git a/tests/unit/com/android/documentsui/prefs/PrefsBackupHelperTest.java b/tests/unit/com/android/documentsui/prefs/PrefsBackupHelperTest.java
new file mode 100644
index 0000000..9cb9cd6
--- /dev/null
+++ b/tests/unit/com/android/documentsui/prefs/PrefsBackupHelperTest.java
@@ -0,0 +1,201 @@
+/*
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.documentsui.prefs;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.fail;
+
+import android.content.SharedPreferences;
+import android.content.SharedPreferences.Editor;
+import android.support.test.InstrumentationRegistry;
+import android.support.test.filters.SmallTest;
+import android.support.test.runner.AndroidJUnit4;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.io.IOException;
+import java.util.Map;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Set;
+
+@RunWith(AndroidJUnit4.class)
+@SmallTest
+public class PrefsBackupHelperTest {
+
+    private static final String LOCAL_PREFERENCE_1 = "rootViewMode-validPreference1";
+    private static final String LOCAL_PREFERENCE_2 = "rootViewMode-validPreference2";
+    private static final String SCOPED_PREFERENCE = "includeDeviceRoot-validPreference";
+    private static final String NON_BACKUP_PREFERENCE = "notBackup-invalidPreference";
+
+    private SharedPreferences mDefaultPrefs;
+    private SharedPreferences mBackupPrefs;
+    private PrefsBackupHelper mPrefsBackupHelper;
+
+    @Before
+    public void setUp() {
+        mDefaultPrefs = InstrumentationRegistry.getContext().getSharedPreferences("prefs1", 0);
+        mBackupPrefs = InstrumentationRegistry.getContext().getSharedPreferences("prefs2", 0);
+        mPrefsBackupHelper = new PrefsBackupHelper(mDefaultPrefs);
+    }
+
+    @Test
+    public void testPrepareBackupFile_BackupLocalPreferences() {
+        mDefaultPrefs.edit().putInt(LOCAL_PREFERENCE_1, 1).commit();
+
+        mPrefsBackupHelper.getBackupPreferences(mBackupPrefs);
+
+        assertEquals(mBackupPrefs.getInt(LOCAL_PREFERENCE_1, 0), 1);
+    }
+
+    @Test
+    public void testPrepareBackupFile_BackupScopedPreferences() {
+        mDefaultPrefs.edit().putBoolean(SCOPED_PREFERENCE, true).commit();
+
+        mPrefsBackupHelper.getBackupPreferences(mBackupPrefs);
+
+        assertEquals(mBackupPrefs.getBoolean(SCOPED_PREFERENCE, false), true);
+    }
+
+    @Test
+    public void testPrepareBackupFile_BackupNotInterestedPreferences() {
+        mDefaultPrefs.edit().putBoolean(NON_BACKUP_PREFERENCE, true).commit();
+
+        mPrefsBackupHelper.getBackupPreferences(mBackupPrefs);
+
+        assertFalse(mBackupPrefs.contains(NON_BACKUP_PREFERENCE));
+    }
+
+    @Test
+    public void testPrepareBackupFile_BackupUnexpectedType() throws Exception {
+        // Currently only Integer and Boolean type are supported.
+        mDefaultPrefs.edit().putString(LOCAL_PREFERENCE_1, "String is not accepted").commit();
+
+        try {
+            mPrefsBackupHelper.getBackupPreferences(mBackupPrefs);
+            fail();
+        } catch(IllegalArgumentException e) {
+
+        } finally {
+            assertFalse(mBackupPrefs.contains(LOCAL_PREFERENCE_1));
+        }
+    }
+
+    @Test
+    public void testRestorePreferences_RestoreLocalPreferences() {
+        mBackupPrefs.edit().putInt(LOCAL_PREFERENCE_1, 1).commit();
+
+        mPrefsBackupHelper.putBackupPreferences(mBackupPrefs);
+
+        assertEquals(mDefaultPrefs.getInt(LOCAL_PREFERENCE_1, 0), 1);
+    }
+
+    @Test
+    public void testRestorePreferences_RestoreScopedPreferences() {
+        mBackupPrefs.edit().putBoolean(SCOPED_PREFERENCE, true).commit();
+
+        mPrefsBackupHelper.putBackupPreferences(mBackupPrefs);
+
+        assertEquals(mDefaultPrefs.getBoolean(SCOPED_PREFERENCE, false), true);
+    }
+
+    @Test
+    public void testEndToEnd() {
+        // Simulating an end to end backup & restore process. At the begining, all preferences are
+        // stored in the default shared preferences file, includes preferences that we don't want
+        // to backup.
+        //
+        // On backup, we copy all preferences that we want to backup to the backup shared
+        // preferences file, and then backup that single file.
+        //
+        // On restore, we restore the backup file first, and then copy all preferences in the backup
+        // file to the app's default shared preferences file.
+
+        SharedPreferences.Editor editor = mDefaultPrefs.edit();
+
+        // Set preferences to the default file, includes preferences that are not backed up.
+        editor.putInt(LOCAL_PREFERENCE_1, 1);
+        editor.putInt(LOCAL_PREFERENCE_2, 2);
+        editor.putBoolean(SCOPED_PREFERENCE, true);
+        editor.putBoolean(NON_BACKUP_PREFERENCE, true);
+        editor.commit();
+
+        // Write all backed up preferences to backup shared preferences file.
+        mPrefsBackupHelper.getBackupPreferences(mBackupPrefs);
+
+        // Assume we are doing backup to the backup file.
+
+        // Clear all preferences in default shared preferences file.
+        editor.clear().commit();
+
+        // Assume we are doing restore to the backup file.
+
+        // Copy all backuped preferences to default shared preferences file.
+        mPrefsBackupHelper.putBackupPreferences(mBackupPrefs);
+
+        // Check all preferences are correctly restored.
+        assertEquals(mDefaultPrefs.getInt(LOCAL_PREFERENCE_1, 0), 1);
+        assertEquals(mDefaultPrefs.getInt(LOCAL_PREFERENCE_2, 0), 2);
+        assertEquals(mDefaultPrefs.getBoolean(SCOPED_PREFERENCE, false), true);
+        assertFalse(mDefaultPrefs.contains(NON_BACKUP_PREFERENCE));
+    }
+
+    @Test
+    public void testPreferenceTypesSupport() {
+        Map<String, Object> map = new HashMap<String, Object>();
+        map.put("int", (Integer) 1);
+        map.put("float", (Float) 0.1f);
+        map.put("long", (Long) 10000000000l);
+        map.put("boolean", true);
+        map.put("String", "String");
+        Set<String> stringSet = new HashSet<String>();
+        stringSet.add("string1");
+        stringSet.add("string2");
+        map.put("StringSet", stringSet);
+
+        // SharedPreferences accept Integer, Float, Long, Boolean, String, Set<String> types.
+        // Currently in DocumentsUI, only Integer and Boolean preferences are backed up.
+        for (Map.Entry<String, ?> entry : map.entrySet()) {
+            String key = entry.getKey();
+            Object value = entry.getValue();
+            Editor editor = mDefaultPrefs.edit().clear();
+            if (value instanceof Integer) {
+                mPrefsBackupHelper.setPreference(editor, entry);
+                editor.apply();
+                assertEquals(mDefaultPrefs.getInt("int", 0), 1);
+            } else if(value instanceof Boolean) {
+                mPrefsBackupHelper.setPreference(editor, entry);
+                editor.apply();
+                assertEquals(mDefaultPrefs.getBoolean("boolean", false), true);
+            } else {
+                try {
+                    mPrefsBackupHelper.setPreference(editor, entry);
+                    fail();
+                } catch(IllegalArgumentException e) {
+
+                } finally {
+                    editor.apply();
+                    assertFalse(mDefaultPrefs.contains(key));
+                }
+            }
+        }
+    }
+}