Merge "Extreme battery saver: Cap max cpu frequency."
diff --git a/services/core/java/com/android/server/power/BatterySaverPolicy.java b/services/core/java/com/android/server/power/BatterySaverPolicy.java
index 3992f8a..336df48 100644
--- a/services/core/java/com/android/server/power/BatterySaverPolicy.java
+++ b/services/core/java/com/android/server/power/BatterySaverPolicy.java
@@ -32,6 +32,7 @@
 import com.android.internal.R;
 import com.android.internal.annotations.GuardedBy;
 import com.android.internal.annotations.VisibleForTesting;
+import com.android.server.power.batterysaver.CpuFrequencies;
 
 import java.io.PrintWriter;
 import java.util.ArrayList;
@@ -40,15 +41,14 @@
 /**
  * Class to decide whether to turn on battery saver mode for specific service
  *
- * TODO: We should probably make {@link #mFilesForInteractive} and {@link #mFilesForNoninteractive}
- * less flexible and just take a list of "CPU number - frequency" pairs. Being able to write
- * anything under /sys/ and /proc/ is too loose.
- *
- * Test: atest BatterySaverPolicyTest
+ * Test:
+ atest ${ANDROID_BUILD_TOP}/frameworks/base/services/tests/servicestests/src/com/android/server/power/BatterySaverPolicyTest.java
  */
 public class BatterySaverPolicy extends ContentObserver {
     private static final String TAG = "BatterySaverPolicy";
 
+    public static final boolean DEBUG = false; // DO NOT SUBMIT WITH TRUE.
+
     // Value of batterySaverGpsMode such that GPS isn't affected by battery saver mode.
     public static final int GPS_MODE_NO_CHANGE = 0;
     // Value of batterySaverGpsMode such that GPS is disabled when battery saver mode
@@ -70,8 +70,8 @@
     private static final String KEY_FORCE_ALL_APPS_STANDBY = "force_all_apps_standby";
     private static final String KEY_OPTIONAL_SENSORS_DISABLED = "optional_sensors_disabled";
 
-    private static final String KEY_FILE_FOR_INTERACTIVE_PREFIX = "file-on:";
-    private static final String KEY_FILE_FOR_NONINTERACTIVE_PREFIX = "file-off:";
+    private static final String KEY_CPU_FREQ_INTERACTIVE = "cpufreq-i";
+    private static final String KEY_CPU_FREQ_NONINTERACTIVE = "cpufreq-n";
 
     private static String mSettings;
     private static String mDeviceSpecificSettings;
@@ -273,6 +273,11 @@
         mSettings = setting;
         mDeviceSpecificSettings = deviceSpecificSetting;
 
+        if (DEBUG) {
+            Slog.i(TAG, "mSettings=" + mSettings);
+            Slog.i(TAG, "mDeviceSpecificSettings=" + mDeviceSpecificSettings);
+        }
+
         final KeyValueListParser parser = new KeyValueListParser(',');
 
         // Non-device-specific parameters.
@@ -307,29 +312,11 @@
                     + deviceSpecificSetting);
         }
 
-        mFilesForInteractive = collectParams(parser, KEY_FILE_FOR_INTERACTIVE_PREFIX);
-        mFilesForNoninteractive = collectParams(parser, KEY_FILE_FOR_NONINTERACTIVE_PREFIX);
-    }
+        mFilesForInteractive = (new CpuFrequencies()).parseString(
+                parser.getString(KEY_CPU_FREQ_INTERACTIVE, "")).toSysFileMap();
 
-    private static ArrayMap<String, String> collectParams(
-            KeyValueListParser parser, String prefix) {
-        final ArrayMap<String, String> ret = new ArrayMap<>();
-
-        for (int i = parser.size() - 1; i >= 0; i--) {
-            final String key = parser.keyAt(i);
-            if (!key.startsWith(prefix)) {
-                continue;
-            }
-            final String path = key.substring(prefix.length());
-
-            if (!(path.startsWith("/sys/") || path.startsWith("/proc/"))) {
-                Slog.wtf(TAG, "Invalid path: " + path);
-                continue;
-            }
-
-            ret.put(path, parser.getString(key, ""));
-        }
-        return ret;
+        mFilesForNoninteractive = (new CpuFrequencies()).parseString(
+                parser.getString(KEY_CPU_FREQ_NONINTERACTIVE, "")).toSysFileMap();
     }
 
     /**
@@ -399,10 +386,10 @@
         synchronized (mLock) {
             pw.println();
             pw.println("Battery saver policy");
-            pw.println("  Settings " + Settings.Global.BATTERY_SAVER_CONSTANTS);
-            pw.println("  value: " + mSettings);
-            pw.println("  Settings " + mDeviceSpecificSettingsSource);
-            pw.println("  value: " + mDeviceSpecificSettings);
+            pw.println("  Settings: " + Settings.Global.BATTERY_SAVER_CONSTANTS);
+            pw.println("    value: " + mSettings);
+            pw.println("  Settings: " + mDeviceSpecificSettingsSource);
+            pw.println("    value: " + mDeviceSpecificSettings);
 
             pw.println();
             pw.println("  " + KEY_VIBRATION_DISABLED + "=" + mVibrationDisabled);
diff --git a/services/core/java/com/android/server/power/batterysaver/BatterySaverController.java b/services/core/java/com/android/server/power/batterysaver/BatterySaverController.java
index 3db6a25..b471c8d 100644
--- a/services/core/java/com/android/server/power/batterysaver/BatterySaverController.java
+++ b/services/core/java/com/android/server/power/batterysaver/BatterySaverController.java
@@ -49,7 +49,7 @@
 public class BatterySaverController implements BatterySaverPolicyListener {
     static final String TAG = "BatterySaverController";
 
-    static final boolean DEBUG = false; // DO NOT MERGE WITH TRUE
+    static final boolean DEBUG = BatterySaverPolicy.DEBUG;
 
     private final Object mLock = new Object();
     private final Context mContext;
diff --git a/services/core/java/com/android/server/power/batterysaver/CpuFrequencies.java b/services/core/java/com/android/server/power/batterysaver/CpuFrequencies.java
new file mode 100644
index 0000000..1629486
--- /dev/null
+++ b/services/core/java/com/android/server/power/batterysaver/CpuFrequencies.java
@@ -0,0 +1,101 @@
+/*
+ * 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.power.batterysaver;
+
+import android.util.ArrayMap;
+import android.util.Slog;
+
+import com.android.internal.annotations.GuardedBy;
+
+import java.util.Map;
+
+
+/**
+ * Helper to parse a list of "core-number:frequency" pairs concatenated with / as a separator,
+ * and convert them into a map of "filename -> value" that should be written to
+ * /sys/.../scaling_max_freq.
+ *
+ * Example input: "0:1900800/4:2500000", which will be converted into:
+ *   "/sys/devices/system/cpu/cpu0/cpufreq/scaling_max_freq" "1900800"
+ *   "/sys/devices/system/cpu/cpu4/cpufreq/scaling_max_freq" "2500000"
+ *
+ * Test:
+ atest $ANDROID_BUILD_TOP/frameworks/base/services/tests/servicestests/src/com/android/server/power/batterysaver/CpuFrequenciesTest.java
+ */
+public class CpuFrequencies {
+    private static final String TAG = "CpuFrequencies";
+
+    private final Object mLock = new Object();
+
+    @GuardedBy("mLock")
+    private final ArrayMap<Integer, Long> mCoreAndFrequencies = new ArrayMap<>();
+
+    public CpuFrequencies() {
+    }
+
+    /**
+     * Parse a string.
+     */
+    public CpuFrequencies parseString(String cpuNumberAndFrequencies) {
+        synchronized (mLock) {
+            mCoreAndFrequencies.clear();
+            try {
+                for (String pair : cpuNumberAndFrequencies.split("/")) {
+                    final String[] coreAndFreq = pair.split(":", 2);
+
+                    if (coreAndFreq.length != 2) {
+                        throw new IllegalArgumentException("Wrong format");
+                    }
+                    final int core = Integer.parseInt(coreAndFreq[0]);
+                    final long freq = Long.parseLong(coreAndFreq[1]);
+
+                    mCoreAndFrequencies.put(core, freq);
+                }
+            } catch (IllegalArgumentException e) {
+                Slog.wtf(TAG, "Invalid configuration: " + cpuNumberAndFrequencies, e);
+            }
+        }
+        return this;
+    }
+
+    /**
+     * Return a new map containing the filename-value pairs.
+     */
+    public ArrayMap<String, String> toSysFileMap() {
+        final ArrayMap<String, String> map = new ArrayMap<>();
+        addToSysFileMap(map);
+        return map;
+    }
+
+    /**
+     * Add the filename-value pairs to an existing map.
+     */
+    public void addToSysFileMap(Map<String, String> map) {
+        synchronized (mLock) {
+            final int size = mCoreAndFrequencies.size();
+
+            for (int i = 0; i < size; i++) {
+                final int core = mCoreAndFrequencies.keyAt(i);
+                final long freq = mCoreAndFrequencies.valueAt(i);
+
+                final String file = "/sys/devices/system/cpu/cpu" + Integer.toString(core) +
+                        "/cpufreq/scaling_max_freq";
+
+                map.put(file, Long.toString(freq));
+            }
+        }
+    }
+}
diff --git a/services/core/java/com/android/server/power/batterysaver/FileUpdater.java b/services/core/java/com/android/server/power/batterysaver/FileUpdater.java
index cfe8fc4..cc1b540 100644
--- a/services/core/java/com/android/server/power/batterysaver/FileUpdater.java
+++ b/services/core/java/com/android/server/power/batterysaver/FileUpdater.java
@@ -16,40 +16,259 @@
 package com.android.server.power.batterysaver;
 
 import android.content.Context;
+import android.os.Handler;
+import android.os.Looper;
 import android.util.ArrayMap;
 import android.util.Slog;
 
+import com.android.internal.annotations.GuardedBy;
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.server.IoThread;
+
+import libcore.io.IoUtils;
+
+import java.io.FileWriter;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Map;
+
 /**
  * Used by {@link BatterySaverController} to write values to /sys/ (and possibly /proc/ too) files
- * with retry and to restore the original values.
+ * with retries. It also support restoring to the file original values.
  *
- * TODO Implement it
+ * Retries are needed because writing to "/sys/.../scaling_max_freq" returns EIO when the current
+ * frequency happens to be above the new max frequency.
+ *
+ * Test:
+ atest $ANDROID_BUILD_TOP/frameworks/base/services/tests/servicestests/src/com/android/server/power/batterysaver/FileUpdaterTest.java
  */
 public class FileUpdater {
     private static final String TAG = BatterySaverController.TAG;
 
     private static final boolean DEBUG = BatterySaverController.DEBUG;
 
+    // Don't do disk access with this lock held.
     private final Object mLock = new Object();
+
     private final Context mContext;
 
+    private final Handler mHandler;
+
+    /**
+     * Filename -> value map that holds pending writes.
+     */
+    @GuardedBy("mLock")
+    private final ArrayMap<String, String> mPendingWrites = new ArrayMap<>();
+
+    /**
+     * Filename -> value that holds the original value of each file.
+     */
+    @GuardedBy("mLock")
+    private final ArrayMap<String, String> mDefaultValues = new ArrayMap<>();
+
+    /** Number of retries. We give up on writing after {@link #MAX_RETRIES} retries. */
+    @GuardedBy("mLock")
+    private int mRetries = 0;
+
+    private final int MAX_RETRIES;
+
+    private final long RETRY_INTERVAL_MS;
+
+    /**
+     * "Official" constructor. Don't use the other constructor in the production code.
+     */
     public FileUpdater(Context context) {
-        mContext = context;
+        this(context, IoThread.get().getLooper(), 10, 5000);
     }
 
+    /**
+     * Constructor for test.
+     */
+    @VisibleForTesting
+    FileUpdater(Context context, Looper looper, int maxRetries, int retryIntervalMs) {
+        mContext = context;
+        mHandler = new Handler(looper);
+
+        MAX_RETRIES = maxRetries;
+        RETRY_INTERVAL_MS = retryIntervalMs;
+    }
+
+    /**
+     * Write values to files. (Note the actual writes happen ASAP but asynchronously.)
+     */
     public void writeFiles(ArrayMap<String, String> fileValues) {
-        if (DEBUG) {
-            final int size = fileValues.size();
-            for (int i = 0; i < size; i++) {
-                Slog.d(TAG, "Writing '" + fileValues.valueAt(i)
-                        + "' to '" + fileValues.keyAt(i) + "'");
+        synchronized (mLock) {
+            for (int i = fileValues.size() - 1; i >= 0; i--) {
+                final String file = fileValues.keyAt(i);
+                final String value = fileValues.valueAt(i);
+
+                if (DEBUG) {
+                    Slog.d(TAG, "Scheduling write: '" + value + "' to '" + file + "'");
+                }
+
+                mPendingWrites.put(file, value);
+
+            }
+            mRetries = 0;
+
+            mHandler.removeCallbacks(mHandleWriteOnHandlerRunnable);
+            mHandler.post(mHandleWriteOnHandlerRunnable);
+        }
+    }
+
+    /**
+     * Restore the default values.
+     */
+    public void restoreDefault() {
+        synchronized (mLock) {
+            if (DEBUG) {
+                Slog.d(TAG, "Resetting file default values.");
+            }
+            mPendingWrites.clear();
+
+            writeFiles(mDefaultValues);
+        }
+    }
+
+    private Runnable mHandleWriteOnHandlerRunnable = () -> handleWriteOnHandler();
+
+    /** Convert map keys into a single string for debug messages. */
+    private String getKeysString(Map<String, String> source) {
+        return new ArrayList<>(source.keySet()).toString();
+    }
+
+    /** Clone an ArrayMap. */
+    private ArrayMap<String, String> cloneMap(ArrayMap<String, String> source) {
+        return new ArrayMap<>(source);
+    }
+
+    /**
+     * Called on the handler and writes {@link #mPendingWrites} to the disk.
+     *
+     * When it about to write to each file for the first time, it'll read the file and store
+     * the original value in {@link #mDefaultValues}.
+     */
+    private void handleWriteOnHandler() {
+        // We don't want to access the disk with the lock held, so copy the pending writes to
+        // a local map.
+        final ArrayMap<String, String> writes;
+        synchronized (mLock) {
+            if (mPendingWrites.size() == 0) {
+                return;
+            }
+
+            if (DEBUG) {
+                Slog.d(TAG, "Writing files: (# retries=" + mRetries + ") " +
+                        getKeysString(mPendingWrites));
+            }
+
+            writes = cloneMap(mPendingWrites);
+        }
+
+        // Then write.
+
+        boolean needRetry = false;
+
+        final int size = writes.size();
+        for (int i = 0; i < size; i++) {
+            final String file = writes.keyAt(i);
+            final String value = writes.valueAt(i);
+
+            // Make sure the default value is loaded.
+            if (!ensureDefaultLoaded(file)) {
+                continue;
+            }
+
+            // Write to the file. When succeeded, remove it from the pending list.
+            // Otherwise, schedule a retry.
+            try {
+                injectWriteToFile(file, value);
+
+                removePendingWrite(file);
+            } catch (IOException e) {
+                needRetry = true;
             }
         }
+        if (needRetry) {
+            scheduleRetry();
+        }
     }
 
-    public void restoreDefault() {
-        if (DEBUG) {
-            Slog.d(TAG, "Resetting file default values");
+    private void removePendingWrite(String file) {
+        synchronized (mLock) {
+            mPendingWrites.remove(file);
         }
     }
+
+    private void scheduleRetry() {
+        synchronized (mLock) {
+            if (mPendingWrites.size() == 0) {
+                return; // Shouldn't happen but just in case.
+            }
+
+            mRetries++;
+            if (mRetries > MAX_RETRIES) {
+                doWtf("Gave up writing files: " + getKeysString(mPendingWrites));
+                return;
+            }
+
+            mHandler.removeCallbacks(mHandleWriteOnHandlerRunnable);
+            mHandler.postDelayed(mHandleWriteOnHandlerRunnable, RETRY_INTERVAL_MS);
+        }
+    }
+
+    /**
+     * Make sure {@link #mDefaultValues} has the default value loaded for {@code file}.
+     *
+     * @return true if the default value is loaded. false if the file cannot be read.
+     */
+    private boolean ensureDefaultLoaded(String file) {
+        // Has the default already?
+        synchronized (mLock) {
+            if (mDefaultValues.containsKey(file)) {
+                return true;
+            }
+        }
+        final String originalValue;
+        try {
+            originalValue = injectReadFromFileTrimmed(file);
+        } catch (IOException e) {
+            // If the file is not readable, assume can't write too.
+            injectWtf("Unable to read from file", e);
+
+            removePendingWrite(file);
+            return false;
+        }
+        synchronized (mLock) {
+            mDefaultValues.put(file, originalValue);
+        }
+        return true;
+    }
+
+    @VisibleForTesting
+    String injectReadFromFileTrimmed(String file) throws IOException {
+        return IoUtils.readFileAsString(file).trim();
+    }
+
+    @VisibleForTesting
+    void injectWriteToFile(String file, String value) throws IOException {
+        if (DEBUG) {
+            Slog.d(TAG, "Writing: '" + value + "' to '" + file + "'");
+        }
+        try (FileWriter out = new FileWriter(file)) {
+            out.write(value);
+        } catch (IOException e) {
+            Slog.w(TAG, "Failed writing '" + value + "' to '" + file + "': " + e.getMessage());
+            throw e;
+        }
+    }
+
+    private void doWtf(String message) {
+        injectWtf(message, null);
+    }
+
+    @VisibleForTesting
+    void injectWtf(String message, Throwable e) {
+        Slog.wtf(TAG, message, e);
+    }
 }
diff --git a/services/tests/servicestests/res/values/strings.xml b/services/tests/servicestests/res/values/strings.xml
index 3ac56bb..57da0af 100644
--- a/services/tests/servicestests/res/values/strings.xml
+++ b/services/tests/servicestests/res/values/strings.xml
@@ -30,6 +30,6 @@
     <string name="test_account_type2">com.android.server.accounts.account_manager_service_test.account.type2</string>
 
     <string name="config_batterySaverDeviceSpecificConfig_1"></string>
-    <string name="config_batterySaverDeviceSpecificConfig_2">file-off:/sys/a=1,file-off:/sys/b=2</string>
-    <string name="config_batterySaverDeviceSpecificConfig_3">file-off:/sys/a=3,file-on:/proc/c=4,/abc=3</string>
+    <string name="config_batterySaverDeviceSpecificConfig_2">cpufreq-n=1:123/2:456</string>
+    <string name="config_batterySaverDeviceSpecificConfig_3">cpufreq-n=2:222,cpufreq-i=3:333/4:444</string>
 </resources>
diff --git a/services/tests/servicestests/src/com/android/server/power/BatterySaverPolicyTest.java b/services/tests/servicestests/src/com/android/server/power/BatterySaverPolicyTest.java
index 0db19e4..20cf733 100644
--- a/services/tests/servicestests/src/com/android/server/power/BatterySaverPolicyTest.java
+++ b/services/tests/servicestests/src/com/android/server/power/BatterySaverPolicyTest.java
@@ -237,21 +237,27 @@
         mBatterySaverPolicy.onChange();
         assertThat(mBatterySaverPolicy.getFileValues(true).toString()).isEqualTo("{}");
         assertThat(mBatterySaverPolicy.getFileValues(false).toString())
-                .isEqualTo("{/sys/a=1, /sys/b=2}");
-
+                .isEqualTo("{/sys/devices/system/cpu/cpu1/cpufreq/scaling_max_freq=123, " +
+                "/sys/devices/system/cpu/cpu2/cpufreq/scaling_max_freq=456}");
 
         mDeviceSpecificConfigResId = R.string.config_batterySaverDeviceSpecificConfig_3;
 
         mBatterySaverPolicy.onChange();
-        assertThat(mBatterySaverPolicy.getFileValues(true).toString()).isEqualTo("{/proc/c=4}");
-        assertThat(mBatterySaverPolicy.getFileValues(false).toString()).isEqualTo("{/sys/a=3}");
+        assertThat(mBatterySaverPolicy.getFileValues(true).toString())
+                .isEqualTo("{/sys/devices/system/cpu/cpu3/cpufreq/scaling_max_freq=333, " +
+                        "/sys/devices/system/cpu/cpu4/cpufreq/scaling_max_freq=444}");
+        assertThat(mBatterySaverPolicy.getFileValues(false).toString())
+                .isEqualTo("{/sys/devices/system/cpu/cpu2/cpufreq/scaling_max_freq=222}");
 
 
         mMockGlobalSettings.put(Global.BATTERY_SAVER_DEVICE_SPECIFIC_CONSTANTS,
-                "file-on:/proc/z=4");
+                "cpufreq-i=3:1234567890/4:014/5:015");
 
         mBatterySaverPolicy.onChange();
-        assertThat(mBatterySaverPolicy.getFileValues(true).toString()).isEqualTo("{/proc/z=4}");
+        assertThat(mBatterySaverPolicy.getFileValues(true).toString())
+                .isEqualTo("{/sys/devices/system/cpu/cpu3/cpufreq/scaling_max_freq=1234567890, " +
+                        "/sys/devices/system/cpu/cpu4/cpufreq/scaling_max_freq=14, " +
+                        "/sys/devices/system/cpu/cpu5/cpufreq/scaling_max_freq=15}");
         assertThat(mBatterySaverPolicy.getFileValues(false).toString()).isEqualTo("{}");
     }
 }
diff --git a/services/tests/servicestests/src/com/android/server/power/batterysaver/CpuFrequenciesTest.java b/services/tests/servicestests/src/com/android/server/power/batterysaver/CpuFrequenciesTest.java
new file mode 100644
index 0000000..f72ec34
--- /dev/null
+++ b/services/tests/servicestests/src/com/android/server/power/batterysaver/CpuFrequenciesTest.java
@@ -0,0 +1,69 @@
+/*
+ * 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.power.batterysaver;
+
+import static org.junit.Assert.assertEquals;
+
+import android.support.test.filters.SmallTest;
+import android.support.test.runner.AndroidJUnit4;
+import android.util.ArrayMap;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/**
+ atest $ANDROID_BUILD_TOP/frameworks/base/services/tests/servicestests/src/com/android/server/power/batterysaver/CpuFrequenciesTest.java
+ */
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public class CpuFrequenciesTest {
+    private void check(ArrayMap<String, String> expected, String config) {
+        assertEquals(expected, (new CpuFrequencies().parseString(config))
+                .toSysFileMap());
+    }
+
+    @Test
+    public void test() {
+        check(new ArrayMap<>(), "");
+
+        final ArrayMap<String, String> expected = new ArrayMap<>();
+
+        expected.clear();
+        expected.put("/sys/devices/system/cpu/cpu0/cpufreq/scaling_max_freq", "0");
+        check(expected, "0:0");
+
+        expected.clear();
+        expected.put("/sys/devices/system/cpu/cpu0/cpufreq/scaling_max_freq", "0");
+        expected.put("/sys/devices/system/cpu/cpu1/cpufreq/scaling_max_freq", "1");
+        check(expected, "0:0/1:1");
+
+        expected.clear();
+        expected.put("/sys/devices/system/cpu/cpu2/cpufreq/scaling_max_freq", "0");
+        expected.put("/sys/devices/system/cpu/cpu1/cpufreq/scaling_max_freq", "1234567890");
+        check(expected, "2:0/1:1234567890");
+
+        expected.clear();
+        expected.put("/sys/devices/system/cpu/cpu0/cpufreq/scaling_max_freq", "1900800");
+        expected.put("/sys/devices/system/cpu/cpu4/cpufreq/scaling_max_freq", "1958400");
+        check(expected, "0:1900800/4:1958400");
+
+        check(expected, "0:1900800/4:1958400/"); // Shouldn't crash.
+        check(expected, "0:1900800/4:1958400/1"); // Shouldn't crash.
+        check(expected, "0:1900800/4:1958400/a:1"); // Shouldn't crash.
+        check(expected, "0:1900800/4:1958400/1:"); // Shouldn't crash.
+        check(expected, "0:1900800/4:1958400/1:b"); // Shouldn't crash.
+    }
+}
diff --git a/services/tests/servicestests/src/com/android/server/power/batterysaver/FileUpdaterTest.java b/services/tests/servicestests/src/com/android/server/power/batterysaver/FileUpdaterTest.java
new file mode 100644
index 0000000..7e2a7d2
--- /dev/null
+++ b/services/tests/servicestests/src/com/android/server/power/batterysaver/FileUpdaterTest.java
@@ -0,0 +1,337 @@
+/*
+ * 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.power.batterysaver;
+
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+import android.content.Context;
+import android.os.Handler;
+import android.os.Looper;
+import android.support.test.InstrumentationRegistry;
+import android.support.test.filters.SmallTest;
+import android.support.test.runner.AndroidJUnit4;
+import android.util.ArrayMap;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentMatchers;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.io.IOException;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+
+/**
+ atest $ANDROID_BUILD_TOP/frameworks/base/services/tests/servicestests/src/com/android/server/power/batterysaver/FileUpdaterTest.java
+ */
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public class FileUpdaterTest {
+
+    private class FileUpdaterTestable extends FileUpdater {
+        FileUpdaterTestable(Context context, Looper looper, int maxRetries, int retryIntervalMs) {
+            super(context, looper, maxRetries, retryIntervalMs);
+        }
+
+        @Override
+        String injectReadFromFileTrimmed(String file) throws IOException {
+            return mInjector.injectReadFromFileTrimmed(file);
+        }
+
+        @Override
+        void injectWriteToFile(String file, String value) throws IOException {
+            mInjector.injectWriteToFile(file, value);
+        }
+
+        @Override
+        void injectWtf(String message, Throwable e) {
+            mInjector.injectWtf(message, e);
+        }
+    }
+
+    private interface Injector {
+        String injectReadFromFileTrimmed(String file) throws IOException;
+        void injectWriteToFile(String file, String value) throws IOException;
+        void injectWtf(String message, Throwable e);
+    }
+
+    private Handler mMainHandler;
+
+    @Mock
+    private Injector mInjector;
+
+    private static final int MAX_RETRIES = 3;
+
+    private FileUpdaterTestable mInstance;
+
+    public static <T> T anyOrNull(Class<T> clazz) {
+        return ArgumentMatchers.argThat(value -> true);
+    }
+
+    public static String anyOrNullString() {
+        return ArgumentMatchers.argThat(value -> true);
+    }
+
+    @Before
+    public void setUp() {
+        mMainHandler = new Handler(Looper.getMainLooper());
+
+        MockitoAnnotations.initMocks(this);
+
+        mInstance = newInstance();
+    }
+
+    private FileUpdaterTestable newInstance() {
+        return new FileUpdaterTestable(
+                InstrumentationRegistry.getContext(),
+                Looper.getMainLooper(),
+                MAX_RETRIES,
+                0 /* retry with no delays*/);
+    }
+
+    private void waitUntilMainHandlerDrain() throws Exception {
+        final CountDownLatch l = new CountDownLatch(1);
+        mMainHandler.post(() -> l.countDown());
+        assertTrue(l.await(5, TimeUnit.SECONDS));
+    }
+
+    private void veriryWtf(int times) {
+        verify(mInjector, times(times)).injectWtf(anyOrNullString(), anyOrNull(Throwable.class));
+    }
+
+    @Test
+    public void testNoWrites() throws Exception {
+        doReturn("111").when(mInjector).injectReadFromFileTrimmed("file1");
+        doReturn("222").when(mInjector).injectReadFromFileTrimmed("file2");
+        doReturn("333").when(mInjector).injectReadFromFileTrimmed("file3");
+
+        // Write
+        final ArrayMap<String, String> values = new ArrayMap<>();
+
+        mInstance.writeFiles(values);
+        waitUntilMainHandlerDrain();
+
+        verify(mInjector, times(0)).injectWriteToFile(anyOrNullString(), anyOrNullString());
+
+        // Reset to default
+        mInstance.restoreDefault();
+        waitUntilMainHandlerDrain();
+
+        verify(mInjector, times(0)).injectWriteToFile(anyOrNullString(), anyOrNullString());
+
+        // No WTF should have happened.
+        veriryWtf(0);
+    }
+
+    @Test
+    public void testSimpleWrite() throws Exception {
+        doReturn("111").when(mInjector).injectReadFromFileTrimmed("file1");
+        doReturn("222").when(mInjector).injectReadFromFileTrimmed("file2");
+        doReturn("333").when(mInjector).injectReadFromFileTrimmed("file3");
+
+        // Write
+        final ArrayMap<String, String> values = new ArrayMap<>();
+        values.put("file1", "11");
+
+        mInstance.writeFiles(values);
+        waitUntilMainHandlerDrain();
+
+        verify(mInjector, times(1)).injectWriteToFile("file1", "11");
+
+        // Reset to default
+        mInstance.restoreDefault();
+        waitUntilMainHandlerDrain();
+
+        verify(mInjector, times(1)).injectWriteToFile("file1", "111");
+
+        // No WTF should have happened.
+        veriryWtf(0);
+    }
+
+    @Test
+    public void testMultiWrites() throws Exception {
+        doReturn("111").when(mInjector).injectReadFromFileTrimmed("file1");
+        doReturn("222").when(mInjector).injectReadFromFileTrimmed("file2");
+        doReturn("333").when(mInjector).injectReadFromFileTrimmed("file3");
+
+        // Write
+        final ArrayMap<String, String> values = new ArrayMap<>();
+        values.put("file1", "11");
+        values.put("file2", "22");
+        values.put("file3", "33");
+
+        mInstance.writeFiles(values);
+        waitUntilMainHandlerDrain();
+
+        verify(mInjector, times(1)).injectWriteToFile("file1", "11");
+        verify(mInjector, times(1)).injectWriteToFile("file2", "22");
+        verify(mInjector, times(1)).injectWriteToFile("file3", "33");
+
+        // Reset to default
+        mInstance.restoreDefault();
+        waitUntilMainHandlerDrain();
+
+        verify(mInjector, times(1)).injectWriteToFile("file1", "111");
+        verify(mInjector, times(1)).injectWriteToFile("file2", "222");
+        verify(mInjector, times(1)).injectWriteToFile("file3", "333");
+
+        // No WTF should have happened.
+        veriryWtf(0);
+    }
+
+    @Test
+    public void testCantReadDefault() throws Exception {
+        doThrow(new IOException("can't read")).when(mInjector).injectReadFromFileTrimmed("file1");
+        doReturn("222").when(mInjector).injectReadFromFileTrimmed("file2");
+
+        // Write
+        final ArrayMap<String, String> values = new ArrayMap<>();
+        values.put("file1", "11");
+        values.put("file2", "22");
+
+        mInstance.writeFiles(values);
+        waitUntilMainHandlerDrain();
+
+        verify(mInjector, times(0)).injectWriteToFile("file1", "11");
+        verify(mInjector, times(1)).injectWriteToFile("file2", "22");
+
+        veriryWtf(1);
+
+        // Reset to default
+        mInstance.restoreDefault();
+        waitUntilMainHandlerDrain();
+
+        verify(mInjector, times(0)).injectWriteToFile("file1", "111");
+        verify(mInjector, times(1)).injectWriteToFile("file2", "222");
+
+        veriryWtf(1);
+    }
+
+    @Test
+    public void testWriteGiveUp() throws Exception {
+        doReturn("111").when(mInjector).injectReadFromFileTrimmed("file1");
+        doReturn("222").when(mInjector).injectReadFromFileTrimmed("file2");
+        doReturn("333").when(mInjector).injectReadFromFileTrimmed("fail1");
+
+        doThrow(new IOException("can't write")).when(mInjector).injectWriteToFile(
+                eq("fail1"), eq("33"));
+
+        // Write
+        final ArrayMap<String, String> values = new ArrayMap<>();
+        values.put("file1", "11");
+        values.put("file2", "22");
+        values.put("fail1", "33");
+
+        mInstance.writeFiles(values);
+        waitUntilMainHandlerDrain();
+
+        verify(mInjector, times(1)).injectWriteToFile("file1", "11");
+        verify(mInjector, times(1)).injectWriteToFile("file2", "22");
+
+        verify(mInjector, times(MAX_RETRIES + 1)).injectWriteToFile("fail1", "33");
+
+        // 1 WTF.
+        veriryWtf(1);
+
+        // Reset to default
+        mInstance.restoreDefault();
+        waitUntilMainHandlerDrain();
+
+        verify(mInjector, times(1)).injectWriteToFile("file1", "111");
+        verify(mInjector, times(1)).injectWriteToFile("file2", "222");
+
+        verify(mInjector, times(1)).injectWriteToFile("fail1", "333");
+
+        // No further WTF.
+        veriryWtf(1);
+    }
+
+    @Test
+    public void testSuccessWithRetry() throws Exception {
+        doReturn("111").when(mInjector).injectReadFromFileTrimmed("file1");
+        doReturn("222").when(mInjector).injectReadFromFileTrimmed("file2");
+        doReturn("333").when(mInjector).injectReadFromFileTrimmed("fail1");
+
+        final AtomicInteger counter = new AtomicInteger();
+        doAnswer((inv) -> {
+            if (counter.getAndIncrement() <= 1) {
+                throw new IOException();
+            }
+            return null;
+            }).when(mInjector).injectWriteToFile(eq("fail1"), eq("33"));
+
+        // Write
+        final ArrayMap<String, String> values = new ArrayMap<>();
+        values.put("file1", "11");
+        values.put("file2", "22");
+        values.put("fail1", "33");
+
+        mInstance.writeFiles(values);
+        waitUntilMainHandlerDrain();
+
+        verify(mInjector, times(1)).injectWriteToFile("file1", "11");
+        verify(mInjector, times(1)).injectWriteToFile("file2", "22");
+
+        // Should succeed after 2 retries.
+        verify(mInjector, times(3)).injectWriteToFile("fail1", "33");
+
+        // No WTF.
+        veriryWtf(0);
+
+        // Reset to default
+        mInstance.restoreDefault();
+        waitUntilMainHandlerDrain();
+
+        verify(mInjector, times(1)).injectWriteToFile("file1", "111");
+        verify(mInjector, times(1)).injectWriteToFile("file2", "222");
+        verify(mInjector, times(1)).injectWriteToFile("fail1", "333");
+
+        // Still no WTF.
+        veriryWtf(0);
+    }
+
+    @Test
+    public void testAll() throws Exception {
+        // Run multiple tests on the single target instance.
+
+        reset(mInjector);
+        testSimpleWrite();
+
+        reset(mInjector);
+        testWriteGiveUp();
+
+        reset(mInjector);
+        testMultiWrites();
+
+        reset(mInjector);
+        testSuccessWithRetry();
+
+        reset(mInjector);
+        testMultiWrites();
+    }
+}