Create thread to dump shutdown check points to file

Create new method ShutdownCheckPoints#createDumpThread that
creates a thread that calls ShutdownCheckPoints#dump on a local file and
also limits the total number of files created by deleting the oldest
ones.

Bug: 113147108
Test: atest FrameworksServicesTests:ShutdownCheckPointsTest
Change-Id: I62c8eedba2ff4c10ad66636c57de07fd1370c116
diff --git a/services/core/java/com/android/server/power/ShutdownCheckPoints.java b/services/core/java/com/android/server/power/ShutdownCheckPoints.java
index c85a77a..e6d0ddd 100644
--- a/services/core/java/com/android/server/power/ShutdownCheckPoints.java
+++ b/services/core/java/com/android/server/power/ShutdownCheckPoints.java
@@ -21,13 +21,20 @@
 import android.app.IActivityManager;
 import android.os.Process;
 import android.os.RemoteException;
+import android.util.AtomicFile;
+import android.util.Log;
 import android.util.Slog;
 
 import com.android.internal.annotations.VisibleForTesting;
 
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.FilenameFilter;
+import java.io.IOException;
 import java.io.PrintWriter;
 import java.text.SimpleDateFormat;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Date;
 import java.util.LinkedList;
 import java.util.List;
@@ -45,6 +52,7 @@
     private static final ShutdownCheckPoints INSTANCE = new ShutdownCheckPoints();
 
     private static final int MAX_CHECK_POINTS = 100;
+    private static final int MAX_DUMP_FILES = 20;
     private static final SimpleDateFormat DATE_FORMAT =
             new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS z");
 
@@ -64,6 +72,11 @@
             }
 
             @Override
+            public int maxDumpFiles() {
+                return MAX_DUMP_FILES;
+            }
+
+            @Override
             public IActivityManager activityManager() {
                 return ActivityManager.getService();
             }
@@ -96,6 +109,15 @@
         INSTANCE.dumpInternal(printWriter);
     }
 
+    /**
+     * Creates a {@link Thread} that calls {@link #dump(PrintWriter)} on a rotating file created
+     * from given {@code baseFile} and a timestamp suffix. Older dump files are also deleted by this
+     * thread.
+     */
+    public static Thread newDumpThread(File baseFile) {
+        return INSTANCE.newDumpThreadInternal(baseFile);
+    }
+
     @VisibleForTesting
     void recordCheckPointInternal() {
         recordCheckPointInternal(new SystemServerCheckPoint(mInjector));
@@ -138,13 +160,21 @@
         }
     }
 
+    @VisibleForTesting
+    Thread newDumpThreadInternal(File baseFile) {
+        return new FileDumperThread(this, baseFile, mInjector.maxDumpFiles());
+    }
+
     /** Injector used by {@link ShutdownCheckPoints} for testing purposes. */
     @VisibleForTesting
     interface Injector {
+
         long currentTimeMillis();
 
         int maxCheckPoints();
 
+        int maxDumpFiles();
+
         IActivityManager activityManager();
     }
 
@@ -296,4 +326,74 @@
             printWriter.println(mPackageName);
         }
     }
+
+    /**
+     * Thread that writes {@link ShutdownCheckPoints#dumpInternal(PrintWriter)} to a new file and
+     * deletes old ones to keep the total number of files down to a given limit.
+     */
+    private static final class FileDumperThread extends Thread {
+
+        private final ShutdownCheckPoints mInstance;
+        private final File mBaseFile;
+        private final int mFileCountLimit;
+
+        FileDumperThread(ShutdownCheckPoints instance, File baseFile, int fileCountLimit) {
+            mInstance = instance;
+            mBaseFile = baseFile;
+            mFileCountLimit = fileCountLimit;
+        }
+
+        @Override
+        public void run() {
+            mBaseFile.getParentFile().mkdirs();
+            File[] checkPointFiles = listCheckPointsFiles();
+
+            int filesToDelete = checkPointFiles.length - mFileCountLimit + 1;
+            for (int i = 0; i < filesToDelete; i++) {
+                checkPointFiles[i].delete();
+            }
+
+            File nextCheckPointsFile = new File(String.format("%s-%d",
+                    mBaseFile.getAbsolutePath(), System.currentTimeMillis()));
+            writeCheckpoints(nextCheckPointsFile);
+        }
+
+        private File[] listCheckPointsFiles() {
+            String filePrefix = mBaseFile.getName() + "-";
+            File[] files = mBaseFile.getParentFile().listFiles(new FilenameFilter() {
+                @Override
+                public boolean accept(File dir, String name) {
+                    if (!name.startsWith(filePrefix)) {
+                        return false;
+                    }
+                    try {
+                        Long.valueOf(name.substring(filePrefix.length()));
+                    } catch (NumberFormatException e) {
+                        return false;
+                    }
+                    return true;
+                }
+            });
+            Arrays.sort(files);
+            return files;
+        }
+
+        private void writeCheckpoints(File file) {
+            AtomicFile tmpFile = new AtomicFile(mBaseFile);
+            FileOutputStream fos = null;
+            try {
+                fos = tmpFile.startWrite();
+                PrintWriter pw = new PrintWriter(fos);
+                mInstance.dumpInternal(pw);
+                pw.flush();
+                tmpFile.finishWrite(fos); // This also closes the output stream.
+            } catch (IOException e) {
+                Log.e(TAG, "Failed to write shutdown checkpoints", e);
+                if (fos != null) {
+                    tmpFile.failWrite(fos); // This also closes the output stream.
+                }
+            }
+            mBaseFile.renameTo(file);
+        }
+    }
 }
diff --git a/services/core/java/com/android/server/power/ShutdownThread.java b/services/core/java/com/android/server/power/ShutdownThread.java
index 2c1fe58..2621d8b 100644
--- a/services/core/java/com/android/server/power/ShutdownThread.java
+++ b/services/core/java/com/android/server/power/ShutdownThread.java
@@ -43,7 +43,6 @@
 import android.os.Vibrator;
 import android.telephony.TelephonyManager;
 import android.util.ArrayMap;
-import android.util.AtomicFile;
 import android.util.Log;
 import android.util.Slog;
 import android.util.TimingsTraceLog;
@@ -57,7 +56,6 @@
 import java.io.File;
 import java.io.FileOutputStream;
 import java.io.IOException;
-import java.io.PrintWriter;
 import java.nio.charset.StandardCharsets;
 
 public final class ShutdownThread extends Thread {
@@ -428,7 +426,9 @@
         metricStarted(METRIC_SYSTEM_SERVER);
 
         // Start dumping check points for this shutdown in a separate thread.
-        Thread dumpCheckPointsThread = startCheckPointsDump();
+        Thread dumpCheckPointsThread = ShutdownCheckPoints.newDumpThread(
+                new File(CHECK_POINTS_FILE_BASENAME));
+        dumpCheckPointsThread.start();
 
         BroadcastReceiver br = new BroadcastReceiver() {
             @Override public void onReceive(Context context, Intent intent) {
@@ -728,25 +728,6 @@
         }
     }
 
-    private Thread startCheckPointsDump() {
-        Thread thread = new Thread() {
-            public void run() {
-                // Writes to a file with .new suffix and then renames it to final file name.
-                // The final file won't be left with partial data if this thread is interrupted.
-                AtomicFile file = new AtomicFile(new File(CHECK_POINTS_FILE_BASENAME));
-                try (FileOutputStream fos = file.startWrite()) {
-                    PrintWriter pw = new PrintWriter(fos);
-                    ShutdownCheckPoints.dump(pw);
-                    file.finishWrite(fos);
-                } catch (IOException e) {
-                    Log.e(TAG, "Cannot save shutdown checkpoints", e);
-                }
-            }
-        };
-        thread.start();
-        return thread;
-    }
-
     private void uncrypt() {
         Log.i(TAG, "Calling uncrypt and monitoring the progress...");
 
diff --git a/services/tests/servicestests/src/com/android/server/power/ShutdownCheckPointsTest.java b/services/tests/servicestests/src/com/android/server/power/ShutdownCheckPointsTest.java
index 7ffd0d0..a1f1056 100644
--- a/services/tests/servicestests/src/com/android/server/power/ShutdownCheckPointsTest.java
+++ b/services/tests/servicestests/src/com/android/server/power/ShutdownCheckPointsTest.java
@@ -33,9 +33,15 @@
 import org.mockito.junit.MockitoJUnit;
 import org.mockito.junit.MockitoRule;
 
+import java.io.File;
+import java.io.IOException;
 import java.io.PrintWriter;
 import java.io.StringWriter;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Paths;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.List;
 import java.util.Locale;
 import java.util.TimeZone;
@@ -59,7 +65,7 @@
     public void setUp() {
         Locale.setDefault(Locale.UK);
         TimeZone.setDefault(TimeZone.getTimeZone("UTC"));
-        mTestInjector = new TestInjector(0, 100, mActivityManager);
+        mTestInjector = new TestInjector(mActivityManager);
         mInstance = new ShutdownCheckPoints(mTestInjector);
     }
 
@@ -207,6 +213,67 @@
                 dumpToString(limitedInstance));
     }
 
+    @Test
+    public void testDumpToFile() throws Exception {
+        File tempDir = createTempDir();
+        File baseFile = new File(tempDir, "checkpoints");
+
+        mTestInjector.setCurrentTime(1000);
+        mInstance.recordCheckPointInternal("first.intent", "first.app");
+        dumpToFile(baseFile);
+
+        mTestInjector.setCurrentTime(2000);
+        mInstance.recordCheckPointInternal("second.intent", "second.app");
+        dumpToFile(baseFile);
+
+        File[] dumpFiles = tempDir.listFiles();
+        Arrays.sort(dumpFiles);
+
+        assertEquals(2, dumpFiles.length);
+        assertEquals(
+                "Shutdown request from INTENT at 1970-01-01 00:00:01.000 UTC (epoch=1000)\n"
+                        + "Intent: first.intent\n"
+                        + "Package: first.app\n\n",
+                readFileAsString(dumpFiles[0].getAbsolutePath()));
+        assertEquals(
+                "Shutdown request from INTENT at 1970-01-01 00:00:01.000 UTC (epoch=1000)\n"
+                        + "Intent: first.intent\n"
+                        + "Package: first.app\n\n"
+                        + "Shutdown request from INTENT at 1970-01-01 00:00:02.000 UTC (epoch=2000)"
+                        + "\n"
+                        + "Intent: second.intent\n"
+                        + "Package: second.app\n\n",
+                readFileAsString(dumpFiles[1].getAbsolutePath()));
+    }
+
+    @Test
+    public void testTooManyFilesDropsOlderOnes() throws Exception {
+        mTestInjector.setDumpFilesLimit(1);
+        ShutdownCheckPoints instance = new ShutdownCheckPoints(mTestInjector);
+        File tempDir = createTempDir();
+        File baseFile = new File(tempDir, "checkpoints");
+
+        mTestInjector.setCurrentTime(1000);
+        instance.recordCheckPointInternal("first.intent", "first.app");
+        dumpToFile(instance, baseFile);
+
+        mTestInjector.setCurrentTime(2000);
+        instance.recordCheckPointInternal("second.intent", "second.app");
+        dumpToFile(instance, baseFile);
+
+        File[] dumpFiles = tempDir.listFiles();
+        assertEquals(1, dumpFiles.length);
+        assertEquals(
+                "Shutdown request from INTENT at 1970-01-01 00:00:01.000 UTC (epoch=1000)\n"
+                        + "Intent: first.intent\n"
+                        + "Package: first.app\n\n"
+                        + "Shutdown request from INTENT at 1970-01-01 00:00:02.000 UTC (epoch=2000)"
+                        + "\n"
+                        + "Intent: second.intent\n"
+                        + "Package: second.app\n\n",
+                readFileAsString(dumpFiles[0].getAbsolutePath()));
+    }
+
     private String dumpToString() {
         return dumpToString(mInstance);
     }
@@ -218,15 +285,39 @@
         return sw.toString();
     }
 
+    private void dumpToFile(File baseFile) throws InterruptedException {
+        dumpToFile(mInstance, baseFile);
+    }
+
+    private void dumpToFile(ShutdownCheckPoints instance, File baseFile)
+            throws InterruptedException {
+        Thread dumpThread = instance.newDumpThreadInternal(baseFile);
+        dumpThread.start();
+        dumpThread.join();
+    }
+
+    private String readFileAsString(String absolutePath) throws IOException {
+        return new String(Files.readAllBytes(Paths.get(absolutePath)), StandardCharsets.UTF_8);
+    }
+
+    private File createTempDir() throws IOException {
+        File tempDir = File.createTempFile("checkpoints", "out");
+        tempDir.delete();
+        tempDir.mkdir();
+        return tempDir;
+    }
+
     /** Fake system dependencies for testing. */
     private final class TestInjector implements ShutdownCheckPoints.Injector {
         private long mNow;
-        private int mLimit;
+        private int mCheckPointsLimit;
+        private int mDumpFilesLimit;
         private IActivityManager mActivityManager;
 
-        TestInjector(long now, int limit, IActivityManager activityManager) {
-            mNow = now;
-            mLimit = limit;
+        TestInjector(IActivityManager activityManager) {
+            mNow = 0;
+            mCheckPointsLimit = 100;
+            mDumpFilesLimit = 2;
             mActivityManager = activityManager;
         }
 
@@ -237,7 +328,12 @@
 
         @Override
         public int maxCheckPoints() {
-            return mLimit;
+            return mCheckPointsLimit;
+        }
+
+        @Override
+        public int maxDumpFiles() {
+            return mDumpFilesLimit;
         }
 
         @Override
@@ -250,7 +346,11 @@
         }
 
         void setCheckPointsLimit(int limit) {
-            mLimit = limit;
+            mCheckPointsLimit = limit;
+        }
+
+        void setDumpFilesLimit(int dumpFilesLimit) {
+            mDumpFilesLimit = dumpFilesLimit;
         }
     }
 }