diff options
8 files changed, 769 insertions, 50 deletions
diff --git a/services/core/java/com/android/server/power/BatterySaverPolicy.java b/services/core/java/com/android/server/power/BatterySaverPolicy.java index 3992f8a566ea..336df48b9adc 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 android.util.Slog; 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 @@ import java.util.List; /** * 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 @@ public class BatterySaverPolicy extends ContentObserver { 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 @@ public class BatterySaverPolicy extends ContentObserver { 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 @@ public class BatterySaverPolicy extends ContentObserver { + deviceSpecificSetting); } - mFilesForInteractive = collectParams(parser, KEY_FILE_FOR_INTERACTIVE_PREFIX); - mFilesForNoninteractive = collectParams(parser, KEY_FILE_FOR_NONINTERACTIVE_PREFIX); - } - - private static ArrayMap<String, String> collectParams( - KeyValueListParser parser, String prefix) { - final ArrayMap<String, String> ret = new ArrayMap<>(); + mFilesForInteractive = (new CpuFrequencies()).parseString( + parser.getString(KEY_CPU_FREQ_INTERACTIVE, "")).toSysFileMap(); - 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 @@ public class BatterySaverPolicy extends ContentObserver { 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 3db6a25f5413..b471c8d9ab60 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 @@ import java.util.ArrayList; 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 000000000000..1629486b9273 --- /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 cfe8fc490e0a..cc1b540e669b 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) { + 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(); + } + } + + 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, "Resetting file default values"); + 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 3ac56bb5d8bc..57da0af42a88 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 0db19e452650..20cf733a860b 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 @@ public class BatterySaverPolicyTest extends AndroidTestCase { 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 000000000000..f72ec3411461 --- /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 000000000000..7e2a7d221c22 --- /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(); + } +} |