Merge "Extract class AppsBackedUpOnThisDeviceJournal from BackupManagerService"
diff --git a/services/backup/java/com/android/server/backup/AppsBackedUpOnThisDeviceJournal.java b/services/backup/java/com/android/server/backup/AppsBackedUpOnThisDeviceJournal.java
new file mode 100644
index 0000000..c942bb2
--- /dev/null
+++ b/services/backup/java/com/android/server/backup/AppsBackedUpOnThisDeviceJournal.java
@@ -0,0 +1,125 @@
+/*
+ * 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.server.backup;
+
+import static com.android.server.backup.RefactoredBackupManagerService.DEBUG;
+
+import android.util.Slog;
+
+import com.android.internal.annotations.GuardedBy;
+
+import java.io.EOFException;
+import java.io.File;
+import java.io.IOException;
+import java.io.RandomAccessFile;
+import java.util.HashSet;
+
+/**
+ * Records which apps have been backed up on this device, persisting it to disk so that it can be
+ * read at subsequent boots. This class is threadsafe.
+ *
+ * <p>This is used to decide, when restoring a package at install time, whether it has been
+ * previously backed up on the current device. If it has been previously backed up it should
+ * restore from the same restore set that the current device has been backing up to. If it has not
+ * been previously backed up, it should restore from the ancestral restore set (i.e., the restore
+ * set that the user's previous device was backing up to).
+ *
+ * <p>NB: this is always backed by the same files within the state directory supplied at
+ * construction.
+ */
+final class AppsBackedUpOnThisDeviceJournal {
+    private static final String TAG = "AppsBackedUpOnThisDeviceJournal";
+    private static final String JOURNAL_FILE_NAME = "processed";
+
+    @GuardedBy("this")
+    private final HashSet<String> mProcessedPackages = new HashSet<>();
+    private final File mStateDirectory;
+
+    /**
+     * Constructs a new journal, loading state from disk if it has been previously persisted.
+     *
+     * @param stateDirectory The directory in which backup state (including journals) is stored.
+     */
+    AppsBackedUpOnThisDeviceJournal(File stateDirectory) {
+        mStateDirectory = stateDirectory;
+        loadFromDisk();
+    }
+
+    /**
+     * Returns {@code true} if {@code packageName} has previously been backed up.
+     */
+    synchronized boolean hasBeenProcessed(String packageName) {
+        return mProcessedPackages.contains(packageName);
+    }
+
+    synchronized void addPackage(String packageName) {
+        if (!mProcessedPackages.add(packageName)) {
+            // This package has already been processed - no need to add it to the journal.
+            return;
+        }
+
+        File journalFile = new File(mStateDirectory, JOURNAL_FILE_NAME);
+
+        try (RandomAccessFile out = new RandomAccessFile(journalFile, "rws")) {
+            out.seek(out.length());
+            out.writeUTF(packageName);
+        } catch (IOException e) {
+            Slog.e(TAG, "Can't log backup of " + packageName + " to " + journalFile);
+        }
+    }
+
+    /**
+     * A copy of the current state of the journal.
+     *
+     * <p>Used only for dumping out information for logging. {@link #hasBeenProcessed(String)}
+     * should be used for efficiently checking whether a package has been backed up before by this
+     * device.
+     *
+     * @return The current set of packages that have been backed up previously.
+     */
+    synchronized HashSet<String> getPackagesCopy() {
+        return new HashSet<>(mProcessedPackages);
+    }
+
+    synchronized void reset() {
+        mProcessedPackages.clear();
+        File journalFile = new File(mStateDirectory, JOURNAL_FILE_NAME);
+        journalFile.delete();
+    }
+
+    private void loadFromDisk() {
+        File journalFile = new File(mStateDirectory, JOURNAL_FILE_NAME);
+
+        if (!journalFile.exists()) {
+            return;
+        }
+
+        try (RandomAccessFile oldJournal = new RandomAccessFile(journalFile, "r")) {
+            while (true) {
+                String packageName = oldJournal.readUTF();
+                if (DEBUG) {
+                    Slog.v(TAG, "   + " + packageName);
+                }
+                mProcessedPackages.add(packageName);
+            }
+        } catch (EOFException e) {
+            // Successfully loaded journal file
+        } catch (IOException e) {
+            Slog.e(TAG, "Error reading processed packages journal", e);
+        }
+    }
+}
diff --git a/services/backup/java/com/android/server/backup/RefactoredBackupManagerService.java b/services/backup/java/com/android/server/backup/RefactoredBackupManagerService.java
index d118917..39f7232 100644
--- a/services/backup/java/com/android/server/backup/RefactoredBackupManagerService.java
+++ b/services/backup/java/com/android/server/backup/RefactoredBackupManagerService.java
@@ -27,7 +27,6 @@
 import static com.android.server.backup.internal.BackupHandler.MSG_RETRY_INIT;
 import static com.android.server.backup.internal.BackupHandler.MSG_RUN_ADB_BACKUP;
 import static com.android.server.backup.internal.BackupHandler.MSG_RUN_ADB_RESTORE;
-import static com.android.server.backup.internal.BackupHandler.MSG_RUN_BACKUP;
 import static com.android.server.backup.internal.BackupHandler.MSG_RUN_CLEAR;
 import static com.android.server.backup.internal.BackupHandler.MSG_RUN_RESTORE;
 import static com.android.server.backup.internal.BackupHandler.MSG_SCHEDULE_BACKUP_PACKAGE;
@@ -127,14 +126,12 @@
 import java.io.ByteArrayOutputStream;
 import java.io.DataInputStream;
 import java.io.DataOutputStream;
-import java.io.EOFException;
 import java.io.File;
 import java.io.FileDescriptor;
 import java.io.FileInputStream;
 import java.io.FileNotFoundException;
 import java.io.FileOutputStream;
 import java.io.IOException;
-import java.io.InputStream;
 import java.io.PrintWriter;
 import java.io.RandomAccessFile;
 import java.security.SecureRandom;
@@ -630,11 +627,9 @@
 
     private final SecureRandom mRng = new SecureRandom();
 
-    // Keep a log of all the apps we've ever backed up, and what the
-    // dataset tokens are for both the current backup dataset and
-    // the ancestral dataset.
-    private File mEverStored;
-    private HashSet<String> mEverStoredApps = new HashSet<>();
+    // Keep a log of all the apps we've ever backed up, and what the dataset tokens are for both
+    // the current backup dataset and the ancestral dataset.
+    private AppsBackedUpOnThisDeviceJournal mAppsBackedUpOnThisDeviceJournal;
 
     private static final int CURRENT_ANCESTRAL_RECORD_VERSION = 1;
     // increment when the schema changes
@@ -821,49 +816,7 @@
             Slog.w(TAG, "Unable to read token file", e);
         }
 
-        // Keep a log of what apps we've ever backed up.  Because we might have
-        // rebooted in the middle of an operation that was removing something from
-        // this log, we sanity-check its contents here and reconstruct it.
-        mEverStored = new File(mBaseStateDir, "processed");
-        File tempProcessedFile = new File(mBaseStateDir, "processed.new");
-
-        // If we were in the middle of removing something from the ever-backed-up
-        // file, there might be a transient "processed.new" file still present.
-        // Ignore it -- we'll validate "processed" against the current package set.
-        if (tempProcessedFile.exists()) {
-            tempProcessedFile.delete();
-        }
-
-        // If there are previous contents, parse them out then start a new
-        // file to continue the recordkeeping.
-        if (mEverStored.exists()) {
-            try (RandomAccessFile temp = new RandomAccessFile(tempProcessedFile, "rws");
-                 RandomAccessFile in = new RandomAccessFile(mEverStored, "r")) {
-                // Loop until we hit EOF
-                while (true) {
-                    String pkg = in.readUTF();
-                    try {
-                        // is this package still present?
-                        mPackageManager.getPackageInfo(pkg, 0);
-                        // if we get here then yes it is; remember it
-                        mEverStoredApps.add(pkg);
-                        temp.writeUTF(pkg);
-                        if (MORE_DEBUG) Slog.v(TAG, "   + " + pkg);
-                    } catch (NameNotFoundException e) {
-                        // nope, this package was uninstalled; don't include it
-                        if (MORE_DEBUG) Slog.v(TAG, "   - " + pkg);
-                    }
-                }
-            } catch (EOFException e) {
-                // Once we've rewritten the backup history log, atomically replace the
-                // old one with the new one then reopen the file for continuing use.
-                if (!tempProcessedFile.renameTo(mEverStored)) {
-                    Slog.e(TAG, "Error renaming " + tempProcessedFile + " to " + mEverStored);
-                }
-            } catch (IOException e) {
-                Slog.e(TAG, "Error in processed file", e);
-            }
-        }
+        mAppsBackedUpOnThisDeviceJournal = new AppsBackedUpOnThisDeviceJournal(mBaseStateDir);
 
         synchronized (mQueueLock) {
             // Resume the full-data backup queue
@@ -1115,9 +1068,7 @@
     // so we must re-upload all saved settings.
     public void resetBackupState(File stateFileDir) {
         synchronized (mQueueLock) {
-            // Wipe the "what we've ever backed up" tracking
-            mEverStoredApps.clear();
-            mEverStored.delete();
+            mAppsBackedUpOnThisDeviceJournal.reset();
 
             mCurrentToken = 0;
             writeRestoreTokens();
@@ -1412,49 +1363,7 @@
     public void logBackupComplete(String packageName) {
         if (packageName.equals(PACKAGE_MANAGER_SENTINEL)) return;
 
-        synchronized (mEverStoredApps) {
-            if (!mEverStoredApps.add(packageName)) return;
-
-            try (RandomAccessFile out = new RandomAccessFile(mEverStored, "rws")) {
-                out.seek(out.length());
-                out.writeUTF(packageName);
-            } catch (IOException e) {
-                Slog.e(TAG, "Can't log backup of " + packageName + " to " + mEverStored);
-            }
-        }
-    }
-
-    // Remove our awareness of having ever backed up the given package
-    void removeEverBackedUp(String packageName) {
-        if (DEBUG) Slog.v(TAG, "Removing backed-up knowledge of " + packageName);
-        if (MORE_DEBUG) Slog.v(TAG, "New set:");
-
-        synchronized (mEverStoredApps) {
-            // Rewrite the file and rename to overwrite.  If we reboot in the middle,
-            // we'll recognize on initialization time that the package no longer
-            // exists and fix it up then.
-            File tempKnownFile = new File(mBaseStateDir, "processed.new");
-            try (RandomAccessFile known = new RandomAccessFile(tempKnownFile, "rws")) {
-                mEverStoredApps.remove(packageName);
-                for (String s : mEverStoredApps) {
-                    known.writeUTF(s);
-                    if (MORE_DEBUG) Slog.v(TAG, "    " + s);
-                }
-                known.close();
-                if (!tempKnownFile.renameTo(mEverStored)) {
-                    throw new IOException("Can't rename " + tempKnownFile + " to " + mEverStored);
-                }
-            } catch (IOException e) {
-                // Bad: we couldn't create the new copy.  For safety's sake we
-                // abandon the whole process and remove all what's-backed-up
-                // state entirely, meaning we'll force a backup pass for every
-                // participant on the next boot or [re]install.
-                Slog.w(TAG, "Error rewriting " + mEverStored, e);
-                mEverStoredApps.clear();
-                tempKnownFile.delete();
-                mEverStored.delete();
-            }
-        }
+        mAppsBackedUpOnThisDeviceJournal.addPackage(packageName);
     }
 
     // Persistently record the current and ancestral backup tokens as well
@@ -1591,7 +1500,7 @@
 
         long token = mAncestralToken;
         synchronized (mQueueLock) {
-            if (mEverStoredApps.contains(packageName)) {
+            if (mAppsBackedUpOnThisDeviceJournal.hasBeenProcessed(packageName)) {
                 if (MORE_DEBUG) {
                     Slog.i(TAG, "App in ever-stored, so using current token");
                 }
@@ -3394,8 +3303,9 @@
                 }
             }
 
-            pw.println("Ever backed up: " + mEverStoredApps.size());
-            for (String pkg : mEverStoredApps) {
+            HashSet<String> processedApps = mAppsBackedUpOnThisDeviceJournal.getPackagesCopy();
+            pw.println("Ever backed up: " + processedApps.size());
+            for (String pkg : processedApps) {
                 pw.println("    " + pkg);
             }
 
diff --git a/services/tests/servicestests/src/com/android/server/backup/AppsBackedUpOnThisDeviceJournalTest.java b/services/tests/servicestests/src/com/android/server/backup/AppsBackedUpOnThisDeviceJournalTest.java
new file mode 100644
index 0000000..093f920
--- /dev/null
+++ b/services/tests/servicestests/src/com/android/server/backup/AppsBackedUpOnThisDeviceJournalTest.java
@@ -0,0 +1,163 @@
+/*
+ * 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.server.backup;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.platform.test.annotations.Presubmit;
+import android.support.test.filters.SmallTest;
+import android.support.test.runner.AndroidJUnit4;
+
+import com.google.android.collect.Sets;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import org.mockito.MockitoAnnotations;
+
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.util.HashSet;
+import java.util.Set;
+
+@SmallTest
+@Presubmit
+@RunWith(AndroidJUnit4.class)
+public class AppsBackedUpOnThisDeviceJournalTest {
+    private static final String JOURNAL_FILE_NAME = "processed";
+
+    private static final String GOOGLE_PHOTOS = "com.google.photos";
+    private static final String GMAIL = "com.google.gmail";
+    private static final String GOOGLE_PLUS = "com.google.plus";
+
+    @Rule public TemporaryFolder mTemporaryFolder = new TemporaryFolder();
+
+    private File mStateDirectory;
+    private AppsBackedUpOnThisDeviceJournal mAppsBackedUpOnThisDeviceJournal;
+
+    @Before
+    public void setUp() throws Exception {
+        MockitoAnnotations.initMocks(this);
+        mStateDirectory = mTemporaryFolder.newFolder();
+        mAppsBackedUpOnThisDeviceJournal = new AppsBackedUpOnThisDeviceJournal(mStateDirectory);
+    }
+
+    @Test
+    public void constructor_loadsAnyPreviousJournalFromDisk() throws Exception {
+        writePermanentJournalPackages(Sets.newHashSet(GOOGLE_PHOTOS, GMAIL));
+
+        AppsBackedUpOnThisDeviceJournal journalFromDisk =
+                new AppsBackedUpOnThisDeviceJournal(mStateDirectory);
+
+        assertThat(journalFromDisk.hasBeenProcessed(GOOGLE_PHOTOS)).isTrue();
+        assertThat(journalFromDisk.hasBeenProcessed(GMAIL)).isTrue();
+    }
+
+    @Test
+    public void hasBeenProcessed_isFalseForAnyPackageFromBlankInit() {
+        assertThat(mAppsBackedUpOnThisDeviceJournal.hasBeenProcessed(GOOGLE_PHOTOS)).isFalse();
+        assertThat(mAppsBackedUpOnThisDeviceJournal.hasBeenProcessed(GMAIL)).isFalse();
+        assertThat(mAppsBackedUpOnThisDeviceJournal.hasBeenProcessed(GOOGLE_PLUS)).isFalse();
+    }
+
+    @Test
+    public void addPackage_addsPackageToObjectState() {
+        mAppsBackedUpOnThisDeviceJournal.addPackage(GOOGLE_PHOTOS);
+
+        assertThat(mAppsBackedUpOnThisDeviceJournal.hasBeenProcessed(GOOGLE_PHOTOS)).isTrue();
+    }
+
+    @Test
+    public void addPackage_addsPackageToFileSystem() throws Exception {
+        mAppsBackedUpOnThisDeviceJournal.addPackage(GOOGLE_PHOTOS);
+
+        assertThat(readJournalPackages()).contains(GOOGLE_PHOTOS);
+    }
+
+    @Test
+    public void getPackagesCopy_returnsTheCurrentState() throws Exception {
+        mAppsBackedUpOnThisDeviceJournal.addPackage(GOOGLE_PHOTOS);
+        mAppsBackedUpOnThisDeviceJournal.addPackage(GMAIL);
+
+        assertThat(mAppsBackedUpOnThisDeviceJournal.getPackagesCopy())
+                .isEqualTo(Sets.newHashSet(GOOGLE_PHOTOS, GMAIL));
+    }
+
+    @Test
+    public void getPackagesCopy_returnsACopy() throws Exception {
+        mAppsBackedUpOnThisDeviceJournal.getPackagesCopy().add(GMAIL);
+
+        assertThat(mAppsBackedUpOnThisDeviceJournal.hasBeenProcessed(GMAIL)).isFalse();
+    }
+
+    @Test
+    public void reset_removesAllPackagesFromObjectState() {
+        mAppsBackedUpOnThisDeviceJournal.addPackage(GOOGLE_PHOTOS);
+        mAppsBackedUpOnThisDeviceJournal.addPackage(GOOGLE_PLUS);
+        mAppsBackedUpOnThisDeviceJournal.addPackage(GMAIL);
+
+        mAppsBackedUpOnThisDeviceJournal.reset();
+
+        assertThat(mAppsBackedUpOnThisDeviceJournal.hasBeenProcessed(GOOGLE_PHOTOS)).isFalse();
+        assertThat(mAppsBackedUpOnThisDeviceJournal.hasBeenProcessed(GMAIL)).isFalse();
+        assertThat(mAppsBackedUpOnThisDeviceJournal.hasBeenProcessed(GOOGLE_PLUS)).isFalse();
+    }
+
+    @Test
+    public void reset_removesAllPackagesFromFileSystem() throws Exception {
+        mAppsBackedUpOnThisDeviceJournal.addPackage(GOOGLE_PHOTOS);
+        mAppsBackedUpOnThisDeviceJournal.addPackage(GOOGLE_PLUS);
+        mAppsBackedUpOnThisDeviceJournal.addPackage(GMAIL);
+
+        mAppsBackedUpOnThisDeviceJournal.reset();
+
+        assertThat(readJournalPackages()).isEmpty();
+    }
+
+    private HashSet<String> readJournalPackages() throws Exception {
+        File journal = new File(mStateDirectory, JOURNAL_FILE_NAME);
+        HashSet<String> packages = new HashSet<>();
+
+        try (FileInputStream fis = new FileInputStream(journal);
+             DataInputStream dis = new DataInputStream(fis)) {
+            while (dis.available() > 0) {
+                packages.add(dis.readUTF());
+            }
+        } catch (FileNotFoundException e) {
+            return new HashSet<>();
+        }
+
+        return packages;
+    }
+
+    private void writePermanentJournalPackages(Set<String> packages) throws Exception {
+        File journal = new File(mStateDirectory, JOURNAL_FILE_NAME);
+
+        try (FileOutputStream fos = new FileOutputStream(journal);
+             DataOutputStream dos = new DataOutputStream(fos)) {
+            for (String packageName : packages) {
+                dos.writeUTF(packageName);
+            }
+        }
+    }
+}