Move network stats to FileRotator pattern.

Split existing network stats into two separate classes: a recorder
which generates historical data based on periodic counter snapshots,
and a collection of historical data with persistance logic.

Recorder keeps a pending history in memory until outstanding data
crosses a specific threshold.  Persisting is handled through a given
FileRotator.  This pattern significantly reduces disk churn and
memory overhead.  Separate UID data from UID tag data, enabling a
shorter rotation cycle.  Migrate existing stats into new structure.

Remove "xt" stats until iptables hooks are ready.  Avoid consuming
Entry values when recording into NetworkStatsHistory.  Assign
operation counts to default route interface.

Introduce "Rewriter" interface in FileRotator with methods to enable
rewriteAll().  Introduce IndentingPrintWriter to handle indenting in
dump() methods.

Bug: 5386531
Change-Id: Ibe086230a17999a197206ca62d45f266225fdff1
diff --git a/core/java/android/net/NetworkStats.java b/core/java/android/net/NetworkStats.java
index e8f60b4..7a1ef66 100644
--- a/core/java/android/net/NetworkStats.java
+++ b/core/java/android/net/NetworkStats.java
@@ -102,6 +102,15 @@
             this.operations = operations;
         }
 
+        public boolean isNegative() {
+            return rxBytes < 0 || rxPackets < 0 || txBytes < 0 || txPackets < 0 || operations < 0;
+        }
+
+        public boolean isEmpty() {
+            return rxBytes == 0 && rxPackets == 0 && txBytes == 0 && txPackets == 0
+                    && operations == 0;
+        }
+
         @Override
         public String toString() {
             final StringBuilder builder = new StringBuilder();
@@ -343,6 +352,7 @@
      * on matching {@link #uid} and {@link #tag} rows. Ignores {@link #iface},
      * since operation counts are at data layer.
      */
+    @Deprecated
     public void spliceOperationsFrom(NetworkStats stats) {
         for (int i = 0; i < size; i++) {
             final int j = stats.findIndex(IFACE_ALL, uid[i], set[i], tag[i]);
@@ -397,7 +407,7 @@
      * Return total of all fields represented by this snapshot object.
      */
     public Entry getTotal(Entry recycle) {
-        return getTotal(recycle, null, UID_ALL);
+        return getTotal(recycle, null, UID_ALL, false);
     }
 
     /**
@@ -405,7 +415,7 @@
      * the requested {@link #uid}.
      */
     public Entry getTotal(Entry recycle, int limitUid) {
-        return getTotal(recycle, null, limitUid);
+        return getTotal(recycle, null, limitUid, false);
     }
 
     /**
@@ -413,7 +423,11 @@
      * the requested {@link #iface}.
      */
     public Entry getTotal(Entry recycle, HashSet<String> limitIface) {
-        return getTotal(recycle, limitIface, UID_ALL);
+        return getTotal(recycle, limitIface, UID_ALL, false);
+    }
+
+    public Entry getTotalIncludingTags(Entry recycle) {
+        return getTotal(recycle, null, UID_ALL, true);
     }
 
     /**
@@ -423,7 +437,8 @@
      * @param limitIface Set of {@link #iface} to include in total; or {@code
      *            null} to include all ifaces.
      */
-    private Entry getTotal(Entry recycle, HashSet<String> limitIface, int limitUid) {
+    private Entry getTotal(
+            Entry recycle, HashSet<String> limitIface, int limitUid, boolean includeTags) {
         final Entry entry = recycle != null ? recycle : new Entry();
 
         entry.iface = IFACE_ALL;
@@ -442,7 +457,7 @@
 
             if (matchesUid && matchesIface) {
                 // skip specific tags, since already counted in TAG_NONE
-                if (tag[i] != TAG_NONE) continue;
+                if (tag[i] != TAG_NONE && !includeTags) continue;
 
                 entry.rxBytes += rxBytes[i];
                 entry.rxPackets += rxPackets[i];
@@ -460,7 +475,7 @@
      * time, and that none of them have disappeared.
      */
     public NetworkStats subtract(NetworkStats right) {
-        return subtract(this, right, null);
+        return subtract(this, right, null, null);
     }
 
     /**
@@ -471,12 +486,12 @@
      * If counters have rolled backwards, they are clamped to {@code 0} and
      * reported to the given {@link NonMonotonicObserver}.
      */
-    public static NetworkStats subtract(
-            NetworkStats left, NetworkStats right, NonMonotonicObserver observer) {
+    public static <C> NetworkStats subtract(
+            NetworkStats left, NetworkStats right, NonMonotonicObserver<C> observer, C cookie) {
         long deltaRealtime = left.elapsedRealtime - right.elapsedRealtime;
         if (deltaRealtime < 0) {
             if (observer != null) {
-                observer.foundNonMonotonic(left, -1, right, -1);
+                observer.foundNonMonotonic(left, -1, right, -1, cookie);
             }
             deltaRealtime = 0;
         }
@@ -510,7 +525,7 @@
                 if (entry.rxBytes < 0 || entry.rxPackets < 0 || entry.txBytes < 0
                         || entry.txPackets < 0 || entry.operations < 0) {
                     if (observer != null) {
-                        observer.foundNonMonotonic(left, i, right, j);
+                        observer.foundNonMonotonic(left, i, right, j, cookie);
                     }
                     entry.rxBytes = Math.max(entry.rxBytes, 0);
                     entry.rxPackets = Math.max(entry.rxPackets, 0);
@@ -663,8 +678,8 @@
         }
     };
 
-    public interface NonMonotonicObserver {
+    public interface NonMonotonicObserver<C> {
         public void foundNonMonotonic(
-                NetworkStats left, int leftIndex, NetworkStats right, int rightIndex);
+                NetworkStats left, int leftIndex, NetworkStats right, int rightIndex, C cookie);
     }
 }
diff --git a/core/java/android/net/NetworkStatsHistory.java b/core/java/android/net/NetworkStatsHistory.java
index 8c01331..faf8a3f 100644
--- a/core/java/android/net/NetworkStatsHistory.java
+++ b/core/java/android/net/NetworkStatsHistory.java
@@ -26,16 +26,18 @@
 import static android.net.NetworkStatsHistory.Entry.UNKNOWN;
 import static android.net.NetworkStatsHistory.ParcelUtils.readLongArray;
 import static android.net.NetworkStatsHistory.ParcelUtils.writeLongArray;
+import static com.android.internal.util.ArrayUtils.total;
 
 import android.os.Parcel;
 import android.os.Parcelable;
 import android.util.MathUtils;
 
+import com.android.internal.util.IndentingPrintWriter;
+
 import java.io.CharArrayWriter;
 import java.io.DataInputStream;
 import java.io.DataOutputStream;
 import java.io.IOException;
-import java.io.PrintWriter;
 import java.net.ProtocolException;
 import java.util.Arrays;
 import java.util.Random;
@@ -74,6 +76,7 @@
     private long[] txBytes;
     private long[] txPackets;
     private long[] operations;
+    private long totalBytes;
 
     public static class Entry {
         public static final long UNKNOWN = -1;
@@ -106,6 +109,12 @@
         if ((fields & FIELD_TX_PACKETS) != 0) txPackets = new long[initialSize];
         if ((fields & FIELD_OPERATIONS) != 0) operations = new long[initialSize];
         bucketCount = 0;
+        totalBytes = 0;
+    }
+
+    public NetworkStatsHistory(NetworkStatsHistory existing, long bucketDuration) {
+        this(bucketDuration, existing.estimateResizeBuckets(bucketDuration));
+        recordEntireHistory(existing);
     }
 
     public NetworkStatsHistory(Parcel in) {
@@ -118,6 +127,7 @@
         txPackets = readLongArray(in);
         operations = readLongArray(in);
         bucketCount = bucketStart.length;
+        totalBytes = in.readLong();
     }
 
     /** {@inheritDoc} */
@@ -130,6 +140,7 @@
         writeLongArray(out, txBytes, bucketCount);
         writeLongArray(out, txPackets, bucketCount);
         writeLongArray(out, operations, bucketCount);
+        out.writeLong(totalBytes);
     }
 
     public NetworkStatsHistory(DataInputStream in) throws IOException {
@@ -144,6 +155,7 @@
                 txPackets = new long[bucketStart.length];
                 operations = new long[bucketStart.length];
                 bucketCount = bucketStart.length;
+                totalBytes = total(rxBytes) + total(txBytes);
                 break;
             }
             case VERSION_ADD_PACKETS:
@@ -158,6 +170,7 @@
                 txPackets = readVarLongArray(in);
                 operations = readVarLongArray(in);
                 bucketCount = bucketStart.length;
+                totalBytes = total(rxBytes) + total(txBytes);
                 break;
             }
             default: {
@@ -208,6 +221,13 @@
     }
 
     /**
+     * Return total bytes represented by this history.
+     */
+    public long getTotalBytes() {
+        return totalBytes;
+    }
+
+    /**
      * Return index of bucket that contains or is immediately before the
      * requested time.
      */
@@ -266,13 +286,16 @@
      * distribute across internal buckets, creating new buckets as needed.
      */
     public void recordData(long start, long end, NetworkStats.Entry entry) {
-        if (entry.rxBytes < 0 || entry.rxPackets < 0 || entry.txBytes < 0 || entry.txPackets < 0
-                || entry.operations < 0) {
+        long rxBytes = entry.rxBytes;
+        long rxPackets = entry.rxPackets;
+        long txBytes = entry.txBytes;
+        long txPackets = entry.txPackets;
+        long operations = entry.operations;
+
+        if (entry.isNegative()) {
             throw new IllegalArgumentException("tried recording negative data");
         }
-        if (entry.rxBytes == 0 && entry.rxPackets == 0 && entry.txBytes == 0 && entry.txPackets == 0
-                && entry.operations == 0) {
-            // nothing to record; skip
+        if (entry.isEmpty()) {
             return;
         }
 
@@ -295,21 +318,23 @@
             if (overlap <= 0) continue;
 
             // integer math each time is faster than floating point
-            final long fracRxBytes = entry.rxBytes * overlap / duration;
-            final long fracRxPackets = entry.rxPackets * overlap / duration;
-            final long fracTxBytes = entry.txBytes * overlap / duration;
-            final long fracTxPackets = entry.txPackets * overlap / duration;
-            final long fracOperations = entry.operations * overlap / duration;
+            final long fracRxBytes = rxBytes * overlap / duration;
+            final long fracRxPackets = rxPackets * overlap / duration;
+            final long fracTxBytes = txBytes * overlap / duration;
+            final long fracTxPackets = txPackets * overlap / duration;
+            final long fracOperations = operations * overlap / duration;
 
             addLong(activeTime, i, overlap);
-            addLong(rxBytes, i, fracRxBytes); entry.rxBytes -= fracRxBytes;
-            addLong(rxPackets, i, fracRxPackets); entry.rxPackets -= fracRxPackets;
-            addLong(txBytes, i, fracTxBytes); entry.txBytes -= fracTxBytes;
-            addLong(txPackets, i, fracTxPackets); entry.txPackets -= fracTxPackets;
-            addLong(operations, i, fracOperations); entry.operations -= fracOperations;
+            addLong(this.rxBytes, i, fracRxBytes); rxBytes -= fracRxBytes;
+            addLong(this.rxPackets, i, fracRxPackets); rxPackets -= fracRxPackets;
+            addLong(this.txBytes, i, fracTxBytes); txBytes -= fracTxBytes;
+            addLong(this.txPackets, i, fracTxPackets); txPackets -= fracTxPackets;
+            addLong(this.operations, i, fracOperations); operations -= fracOperations;
 
             duration -= overlap;
         }
+
+        totalBytes += entry.rxBytes + entry.txBytes;
     }
 
     /**
@@ -394,6 +419,7 @@
     /**
      * Remove buckets older than requested cutoff.
      */
+    @Deprecated
     public void removeBucketsBefore(long cutoff) {
         int i;
         for (i = 0; i < bucketCount; i++) {
@@ -415,6 +441,8 @@
             if (txPackets != null) txPackets = Arrays.copyOfRange(txPackets, i, length);
             if (operations != null) operations = Arrays.copyOfRange(operations, i, length);
             bucketCount -= i;
+
+            // TODO: subtract removed values from totalBytes
         }
     }
 
@@ -527,19 +555,17 @@
         return (long) (start + (r.nextFloat() * (end - start)));
     }
 
-    public void dump(String prefix, PrintWriter pw, boolean fullHistory) {
-        pw.print(prefix);
+    public void dump(IndentingPrintWriter pw, boolean fullHistory) {
         pw.print("NetworkStatsHistory: bucketDuration="); pw.println(bucketDuration);
+        pw.increaseIndent();
 
         final int start = fullHistory ? 0 : Math.max(0, bucketCount - 32);
         if (start > 0) {
-            pw.print(prefix);
-            pw.print("  (omitting "); pw.print(start); pw.println(" buckets)");
+            pw.print("(omitting "); pw.print(start); pw.println(" buckets)");
         }
 
         for (int i = start; i < bucketCount; i++) {
-            pw.print(prefix);
-            pw.print("  bucketStart="); pw.print(bucketStart[i]);
+            pw.print("bucketStart="); pw.print(bucketStart[i]);
             if (activeTime != null) { pw.print(" activeTime="); pw.print(activeTime[i]); }
             if (rxBytes != null) { pw.print(" rxBytes="); pw.print(rxBytes[i]); }
             if (rxPackets != null) { pw.print(" rxPackets="); pw.print(rxPackets[i]); }
@@ -548,12 +574,14 @@
             if (operations != null) { pw.print(" operations="); pw.print(operations[i]); }
             pw.println();
         }
+
+        pw.decreaseIndent();
     }
 
     @Override
     public String toString() {
         final CharArrayWriter writer = new CharArrayWriter();
-        dump("", new PrintWriter(writer), false);
+        dump(new IndentingPrintWriter(writer, "  "), false);
         return writer.toString();
     }
 
@@ -579,6 +607,10 @@
         if (array != null) array[i] += value;
     }
 
+    public int estimateResizeBuckets(long newBucketDuration) {
+        return (int) (size() * getBucketDuration() / newBucketDuration);
+    }
+
     /**
      * Utility methods for interacting with {@link DataInputStream} and
      * {@link DataOutputStream}, mostly dealing with writing partial arrays.
diff --git a/core/java/android/net/TrafficStats.java b/core/java/android/net/TrafficStats.java
index 8bdb669..dfdea38 100644
--- a/core/java/android/net/TrafficStats.java
+++ b/core/java/android/net/TrafficStats.java
@@ -195,7 +195,7 @@
             // subtract starting values and return delta
             final NetworkStats profilingStop = getDataLayerSnapshotForUid(context);
             final NetworkStats profilingDelta = NetworkStats.subtract(
-                    profilingStop, sActiveProfilingStart, null);
+                    profilingStop, sActiveProfilingStart, null, null);
             sActiveProfilingStart = null;
             return profilingDelta;
         }
diff --git a/core/java/android/provider/Settings.java b/core/java/android/provider/Settings.java
index 0202c47..2fcfa79 100644
--- a/core/java/android/provider/Settings.java
+++ b/core/java/android/provider/Settings.java
@@ -4104,17 +4104,38 @@
         /** {@hide} */
         public static final String NETSTATS_POLL_INTERVAL = "netstats_poll_interval";
         /** {@hide} */
-        public static final String NETSTATS_PERSIST_THRESHOLD = "netstats_persist_threshold";
+        public static final String NETSTATS_TIME_CACHE_MAX_AGE = "netstats_time_cache_max_age";
         /** {@hide} */
-        public static final String NETSTATS_NETWORK_BUCKET_DURATION = "netstats_network_bucket_duration";
+        public static final String NETSTATS_GLOBAL_ALERT_BYTES = "netstats_global_alert_bytes";
         /** {@hide} */
-        public static final String NETSTATS_NETWORK_MAX_HISTORY = "netstats_network_max_history";
+        public static final String NETSTATS_SAMPLE_ENABLED = "netstats_sample_enabled";
+
+        /** {@hide} */
+        public static final String NETSTATS_DEV_BUCKET_DURATION = "netstats_dev_bucket_duration";
+        /** {@hide} */
+        public static final String NETSTATS_DEV_PERSIST_BYTES = "netstats_dev_persist_bytes";
+        /** {@hide} */
+        public static final String NETSTATS_DEV_ROTATE_AGE = "netstats_dev_rotate_age";
+        /** {@hide} */
+        public static final String NETSTATS_DEV_DELETE_AGE = "netstats_dev_delete_age";
+
         /** {@hide} */
         public static final String NETSTATS_UID_BUCKET_DURATION = "netstats_uid_bucket_duration";
         /** {@hide} */
-        public static final String NETSTATS_UID_MAX_HISTORY = "netstats_uid_max_history";
+        public static final String NETSTATS_UID_PERSIST_BYTES = "netstats_uid_persist_bytes";
         /** {@hide} */
-        public static final String NETSTATS_TAG_MAX_HISTORY = "netstats_tag_max_history";
+        public static final String NETSTATS_UID_ROTATE_AGE = "netstats_uid_rotate_age";
+        /** {@hide} */
+        public static final String NETSTATS_UID_DELETE_AGE = "netstats_uid_delete_age";
+
+        /** {@hide} */
+        public static final String NETSTATS_UID_TAG_BUCKET_DURATION = "netstats_uid_tag_bucket_duration";
+        /** {@hide} */
+        public static final String NETSTATS_UID_TAG_PERSIST_BYTES = "netstats_uid_tag_persist_bytes";
+        /** {@hide} */
+        public static final String NETSTATS_UID_TAG_ROTATE_AGE = "netstats_uid_tag_rotate_age";
+        /** {@hide} */
+        public static final String NETSTATS_UID_TAG_DELETE_AGE = "netstats_uid_tag_delete_age";
 
         /** Preferred NTP server. {@hide} */
         public static final String NTP_SERVER = "ntp_server";
diff --git a/core/java/com/android/internal/util/ArrayUtils.java b/core/java/com/android/internal/util/ArrayUtils.java
index edeb2a8..d1aa1ce 100644
--- a/core/java/com/android/internal/util/ArrayUtils.java
+++ b/core/java/com/android/internal/util/ArrayUtils.java
@@ -142,6 +142,14 @@
         return false;
     }
 
+    public static long total(long[] array) {
+        long total = 0;
+        for (long value : array) {
+            total += value;
+        }
+        return total;
+    }
+
     /**
      * Appends an element to a copy of the array and returns the copy.
      * @param array The original array, or null to represent an empty array.
diff --git a/core/java/com/android/internal/util/FileRotator.java b/core/java/com/android/internal/util/FileRotator.java
index 3ce95e7..8a8f315 100644
--- a/core/java/com/android/internal/util/FileRotator.java
+++ b/core/java/com/android/internal/util/FileRotator.java
@@ -17,9 +17,9 @@
 package com.android.internal.util;
 
 import android.os.FileUtils;
+import android.util.Slog;
 
-import com.android.internal.util.FileRotator.Reader;
-import com.android.internal.util.FileRotator.Writer;
+import com.android.internal.util.FileRotator.Rewriter;
 
 import java.io.BufferedInputStream;
 import java.io.BufferedOutputStream;
@@ -41,12 +41,15 @@
  * Instead of manipulating files directly, users implement interfaces that
  * perform operations on {@link InputStream} and {@link OutputStream}. This
  * enables atomic rewriting of file contents in
- * {@link #combineActive(Reader, Writer, long)}.
+ * {@link #rewriteActive(Rewriter, long)}.
  * <p>
  * Users must periodically call {@link #maybeRotate(long)} to perform actual
  * rotation. Not inherently thread safe.
  */
 public class FileRotator {
+    private static final String TAG = "FileRotator";
+    private static final boolean LOGD = true;
+
     private final File mBasePath;
     private final String mPrefix;
     private final long mRotateAgeMillis;
@@ -73,6 +76,15 @@
     }
 
     /**
+     * External class that reads existing data from given {@link InputStream},
+     * then writes any modified data to {@link OutputStream}.
+     */
+    public interface Rewriter extends Reader, Writer {
+        public void reset();
+        public boolean shouldWrite();
+    }
+
+    /**
      * Create a file rotator.
      *
      * @param basePath Directory under which all files will be placed.
@@ -96,6 +108,8 @@
             if (!name.startsWith(mPrefix)) continue;
 
             if (name.endsWith(SUFFIX_BACKUP)) {
+                if (LOGD) Slog.d(TAG, "recovering " + name);
+
                 final File backupFile = new File(mBasePath, name);
                 final File file = new File(
                         mBasePath, name.substring(0, name.length() - SUFFIX_BACKUP.length()));
@@ -104,6 +118,8 @@
                 backupFile.renameTo(file);
 
             } else if (name.endsWith(SUFFIX_NO_BACKUP)) {
+                if (LOGD) Slog.d(TAG, "recovering " + name);
+
                 final File noBackupFile = new File(mBasePath, name);
                 final File file = new File(
                         mBasePath, name.substring(0, name.length() - SUFFIX_NO_BACKUP.length()));
@@ -116,26 +132,95 @@
     }
 
     /**
-     * Atomically combine data with existing data in currently active file.
-     * Maintains a backup during write, which is restored if the write fails.
+     * Delete all files managed by this rotator.
      */
-    public void combineActive(Reader reader, Writer writer, long currentTimeMillis)
+    public void deleteAll() {
+        final FileInfo info = new FileInfo(mPrefix);
+        for (String name : mBasePath.list()) {
+            if (!info.parse(name)) continue;
+
+            // delete each file that matches parser
+            new File(mBasePath, name).delete();
+        }
+    }
+
+    /**
+     * Process currently active file, first reading any existing data, then
+     * writing modified data. Maintains a backup during write, which is restored
+     * if the write fails.
+     */
+    public void rewriteActive(Rewriter rewriter, long currentTimeMillis)
             throws IOException {
         final String activeName = getActiveName(currentTimeMillis);
+        rewriteSingle(rewriter, activeName);
+    }
 
-        final File file = new File(mBasePath, activeName);
+    @Deprecated
+    public void combineActive(final Reader reader, final Writer writer, long currentTimeMillis)
+            throws IOException {
+        rewriteActive(new Rewriter() {
+            /** {@inheritDoc} */
+            public void reset() {
+                // ignored
+            }
+
+            /** {@inheritDoc} */
+            public void read(InputStream in) throws IOException {
+                reader.read(in);
+            }
+
+            /** {@inheritDoc} */
+            public boolean shouldWrite() {
+                return true;
+            }
+
+            /** {@inheritDoc} */
+            public void write(OutputStream out) throws IOException {
+                writer.write(out);
+            }
+        }, currentTimeMillis);
+    }
+
+    /**
+     * Process all files managed by this rotator, usually to rewrite historical
+     * data. Each file is processed atomically.
+     */
+    public void rewriteAll(Rewriter rewriter) throws IOException {
+        final FileInfo info = new FileInfo(mPrefix);
+        for (String name : mBasePath.list()) {
+            if (!info.parse(name)) continue;
+
+            // process each file that matches parser
+            rewriteSingle(rewriter, name);
+        }
+    }
+
+    /**
+     * Process a single file atomically, first reading any existing data, then
+     * writing modified data. Maintains a backup during write, which is restored
+     * if the write fails.
+     */
+    private void rewriteSingle(Rewriter rewriter, String name) throws IOException {
+        if (LOGD) Slog.d(TAG, "rewriting " + name);
+
+        final File file = new File(mBasePath, name);
         final File backupFile;
 
+        rewriter.reset();
+
         if (file.exists()) {
             // read existing data
-            readFile(file, reader);
+            readFile(file, rewriter);
+
+            // skip when rewriter has nothing to write
+            if (!rewriter.shouldWrite()) return;
 
             // backup existing data during write
-            backupFile = new File(mBasePath, activeName + SUFFIX_BACKUP);
+            backupFile = new File(mBasePath, name + SUFFIX_BACKUP);
             file.renameTo(backupFile);
 
             try {
-                writeFile(file, writer);
+                writeFile(file, rewriter);
 
                 // write success, delete backup
                 backupFile.delete();
@@ -148,11 +233,11 @@
 
         } else {
             // create empty backup during write
-            backupFile = new File(mBasePath, activeName + SUFFIX_NO_BACKUP);
+            backupFile = new File(mBasePath, name + SUFFIX_NO_BACKUP);
             backupFile.createNewFile();
 
             try {
-                writeFile(file, writer);
+                writeFile(file, rewriter);
 
                 // write success, delete empty backup
                 backupFile.delete();
@@ -176,6 +261,8 @@
 
             // read file when it overlaps
             if (info.startMillis <= matchEndMillis && matchStartMillis <= info.endMillis) {
+                if (LOGD) Slog.d(TAG, "reading matching " + name);
+
                 final File file = new File(mBasePath, name);
                 readFile(file, reader);
             }
@@ -224,16 +311,20 @@
             if (!info.parse(name)) continue;
 
             if (info.isActive()) {
-                // found active file; rotate if old enough
-                if (info.startMillis < rotateBefore) {
+                if (info.startMillis <= rotateBefore) {
+                    // found active file; rotate if old enough
+                    if (LOGD) Slog.d(TAG, "rotating " + name);
+
                     info.endMillis = currentTimeMillis;
 
                     final File file = new File(mBasePath, name);
                     final File destFile = new File(mBasePath, info.build());
                     file.renameTo(destFile);
                 }
-            } else if (info.endMillis < deleteBefore) {
+            } else if (info.endMillis <= deleteBefore) {
                 // found rotated file; delete if old enough
+                if (LOGD) Slog.d(TAG, "deleting " + name);
+
                 final File file = new File(mBasePath, name);
                 file.delete();
             }
diff --git a/core/java/com/android/internal/util/IndentingPrintWriter.java b/core/java/com/android/internal/util/IndentingPrintWriter.java
new file mode 100644
index 0000000..3dd2284
--- /dev/null
+++ b/core/java/com/android/internal/util/IndentingPrintWriter.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright (C) 2012 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.internal.util;
+
+import java.io.PrintWriter;
+import java.io.Writer;
+
+/**
+ * Lightweight wrapper around {@link PrintWriter} that automatically indents
+ * newlines based on internal state. Delays writing indent until first actual
+ * write on a newline, enabling indent modification after newline.
+ */
+public class IndentingPrintWriter extends PrintWriter {
+    private final String mIndent;
+
+    private StringBuilder mBuilder = new StringBuilder();
+    private String mCurrent = new String();
+    private boolean mEmptyLine = true;
+
+    public IndentingPrintWriter(Writer writer, String indent) {
+        super(writer);
+        mIndent = indent;
+    }
+
+    public void increaseIndent() {
+        mBuilder.append(mIndent);
+        mCurrent = mBuilder.toString();
+    }
+
+    public void decreaseIndent() {
+        mBuilder.delete(0, mIndent.length());
+        mCurrent = mBuilder.toString();
+    }
+
+    @Override
+    public void println() {
+        super.println();
+        mEmptyLine = true;
+    }
+
+    @Override
+    public void write(char[] buf, int offset, int count) {
+        if (mEmptyLine) {
+            mEmptyLine = false;
+            super.print(mCurrent);
+        }
+        super.write(buf, offset, count);
+    }
+}