Extract class AppsBackedUpOnThisDeviceJournal from BackupManagerService

Puts all the logic for remembering what apps have been backed up on
the current device into own class. Also fixes bug where if an app was
uninstalled, it was removed from the journal. As the journal is used
to decide what restore set to use after a fresh install of an app (as
at this point we do try to restore previous state if it's available)
it doesn't make sense to forget apps that were uninstalled.

Bug: 36850431
Test: adb shell am instrument -w -e package com.android.server.backup com.android.frameworks.servicestests/android.support.test.runner.AndroidJUnitRunner
Change-Id: I9300883e139ee0773acbf4a09b08c7f5955c66e5
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);
+            }
+        }
+    }
+}