diff --git a/core/java/com/android/internal/os/BatteryStatsImpl.java b/core/java/com/android/internal/os/BatteryStatsImpl.java index 5dfc5faead433..a7c2bbd7b1ca9 100644 --- a/core/java/com/android/internal/os/BatteryStatsImpl.java +++ b/core/java/com/android/internal/os/BatteryStatsImpl.java @@ -341,6 +341,19 @@ public class BatteryStatsImpl extends BatteryStats { } } + /** + * Listener for the battery stats reset. + */ + public interface BatteryResetListener { + + /** + * Callback invoked immediately prior to resetting battery stats. + */ + void prepareForBatteryStatsReset(); + } + + private BatteryResetListener mBatteryResetListener; + public interface BatteryCallback { public void batteryNeedsCpuUpdate(); public void batteryPowerChanged(boolean onBattery); @@ -11184,6 +11197,10 @@ public class BatteryStatsImpl extends BatteryStats { mDischargeCounter.reset(false, elapsedRealtimeUs); } + public void setBatteryResetListener(BatteryResetListener batteryResetListener) { + mBatteryResetListener = batteryResetListener; + } + public void resetAllStatsCmdLocked() { final long mSecUptime = mClocks.uptimeMillis(); long uptimeUs = mSecUptime * 1000; @@ -11219,6 +11236,10 @@ public class BatteryStatsImpl extends BatteryStats { } private void resetAllStatsLocked(long uptimeMillis, long elapsedRealtimeMillis) { + if (mBatteryResetListener != null) { + mBatteryResetListener.prepareForBatteryStatsReset(); + } + final long uptimeUs = uptimeMillis * 1000; final long elapsedRealtimeUs = elapsedRealtimeMillis * 1000; mStartCount = 0; diff --git a/core/java/com/android/internal/os/BatteryUsageStatsStore.java b/core/java/com/android/internal/os/BatteryUsageStatsStore.java new file mode 100644 index 0000000000000..32cd958b012c9 --- /dev/null +++ b/core/java/com/android/internal/os/BatteryUsageStatsStore.java @@ -0,0 +1,231 @@ +/* + * Copyright (C) 2021 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.os; + +import android.annotation.Nullable; +import android.content.Context; +import android.os.BatteryUsageStats; +import android.os.BatteryUsageStatsQuery; +import android.os.Handler; +import android.util.LongArray; +import android.util.Slog; +import android.util.TypedXmlPullParser; +import android.util.TypedXmlSerializer; +import android.util.Xml; + +import com.android.internal.annotations.VisibleForTesting; + +import org.xmlpull.v1.XmlPullParserException; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.channels.FileChannel; +import java.nio.channels.FileLock; +import java.nio.charset.StandardCharsets; +import java.nio.file.StandardOpenOption; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.TreeMap; + +/** + * A storage mechanism for BatteryUsageStats snapshots. + */ +public class BatteryUsageStatsStore { + private static final String TAG = "BatteryUsageStatsStore"; + + private static final List BATTERY_USAGE_STATS_QUERY = List.of( + new BatteryUsageStatsQuery.Builder() + .setMaxStatsAgeMs(0) + .includePowerModels() + .build()); + private static final String BATTERY_USAGE_STATS_DIR = "battery-usage-stats"; + private static final String SNAPSHOT_FILE_EXTENSION = ".bus"; + private static final String DIR_LOCK_FILENAME = ".lock"; + private static final long MAX_BATTERY_STATS_SNAPSHOT_STORAGE_BYTES = 100 * 1024; + + private final Context mContext; + private final BatteryStatsImpl mBatteryStats; + private final File mStoreDir; + private final File mLockFile; + private final long mMaxStorageBytes; + private final Handler mHandler; + private final BatteryUsageStatsProvider mBatteryUsageStatsProvider; + + public BatteryUsageStatsStore(Context context, BatteryStatsImpl stats, File systemDir, + Handler handler) { + this(context, stats, systemDir, handler, MAX_BATTERY_STATS_SNAPSHOT_STORAGE_BYTES); + } + + @VisibleForTesting + public BatteryUsageStatsStore(Context context, BatteryStatsImpl batteryStats, File systemDir, + Handler handler, long maxStorageBytes) { + mContext = context; + mBatteryStats = batteryStats; + mStoreDir = new File(systemDir, BATTERY_USAGE_STATS_DIR); + mLockFile = new File(mStoreDir, DIR_LOCK_FILENAME); + mHandler = handler; + mMaxStorageBytes = maxStorageBytes; + mBatteryStats.setBatteryResetListener(this::prepareForBatteryStatsReset); + mBatteryUsageStatsProvider = new BatteryUsageStatsProvider(mContext, mBatteryStats); + } + + private void prepareForBatteryStatsReset() { + final List stats = + mBatteryUsageStatsProvider.getBatteryUsageStats(BATTERY_USAGE_STATS_QUERY); + if (stats.isEmpty()) { + Slog.wtf(TAG, "No battery usage stats generated"); + return; + } + + mHandler.post(() -> storeBatteryUsageStats(stats.get(0))); + } + + private void storeBatteryUsageStats(BatteryUsageStats stats) { + try (FileLock lock = lockSnapshotDirectory()) { + if (!mStoreDir.exists()) { + if (!mStoreDir.mkdirs()) { + Slog.e(TAG, + "Could not create a directory for battery usage stats snapshots"); + return; + } + } + File file = makeSnapshotFilename(stats.getStatsEndTimestamp()); + try { + writeXmlFileLocked(stats, file); + } catch (Exception e) { + Slog.e(TAG, "Cannot save battery usage stats", e); + } + + removeOldSnapshotsLocked(); + } catch (IOException e) { + Slog.e(TAG, "Cannot lock battery usage stats directory", e); + } + } + + /** + * Returns the timestamps of the stored BatteryUsageStats snapshots. The timestamp corresponds + * to the time the snapshot was taken {@link BatteryUsageStats#getStatsEndTimestamp()}. + */ + public long[] listBatteryUsageStatsTimestamps() { + LongArray timestamps = new LongArray(100); + try (FileLock lock = lockSnapshotDirectory()) { + for (File file : mStoreDir.listFiles()) { + String fileName = file.getName(); + if (fileName.endsWith(SNAPSHOT_FILE_EXTENSION)) { + try { + String fileNameWithoutExtension = fileName.substring(0, + fileName.length() - SNAPSHOT_FILE_EXTENSION.length()); + timestamps.add(Long.parseLong(fileNameWithoutExtension)); + } catch (NumberFormatException e) { + Slog.wtf(TAG, "Invalid format of BatteryUsageStats snapshot file name: " + + fileName); + } + } + } + } catch (IOException e) { + Slog.e(TAG, "Cannot lock battery usage stats directory", e); + } + return timestamps.toArray(); + } + + /** + * Reads the specified snapshot of BatteryUsageStats. Returns null if the snapshot + * does not exist. + */ + @Nullable + public BatteryUsageStats loadBatteryUsageStats(long timestamp) { + try (FileLock lock = lockSnapshotDirectory()) { + File file = makeSnapshotFilename(timestamp); + try { + return readXmlFileLocked(file); + } catch (Exception e) { + Slog.e(TAG, "Cannot read battery usage stats", e); + } + } catch (IOException e) { + Slog.e(TAG, "Cannot lock battery usage stats directory", e); + } + return null; + } + + private FileLock lockSnapshotDirectory() throws IOException { + mLockFile.getParentFile().mkdirs(); + mLockFile.createNewFile(); + return FileChannel.open(mLockFile.toPath(), StandardOpenOption.WRITE).lock(); + } + + /** + * Creates a file name by formatting the timestamp as 19-digit zero-padded number. + * This ensures that sorted directory list follows the chronological order. + */ + private File makeSnapshotFilename(long statsEndTimestamp) { + return new File(mStoreDir, String.format(Locale.ENGLISH, "%019d", statsEndTimestamp) + + SNAPSHOT_FILE_EXTENSION); + } + + private void writeXmlFileLocked(BatteryUsageStats stats, File file) throws IOException { + try (OutputStream out = new FileOutputStream(file)) { + TypedXmlSerializer serializer = Xml.newBinarySerializer(); + serializer.setOutput(out, StandardCharsets.UTF_8.name()); + serializer.startDocument(null, true); + stats.writeXml(serializer); + serializer.endDocument(); + } + } + + private BatteryUsageStats readXmlFileLocked(File file) + throws IOException, XmlPullParserException { + try (InputStream in = new FileInputStream(file)) { + TypedXmlPullParser parser = Xml.newBinaryPullParser(); + parser.setInput(in, StandardCharsets.UTF_8.name()); + return BatteryUsageStats.createFromXml(parser); + } + } + + private void removeOldSnapshotsLocked() { + // Read the directory list into a _sorted_ map. The alphanumeric ordering + // corresponds to the historical order of snapshots because the file names + // are timestamps zero-padded to the same length. + long totalSize = 0; + TreeMap mFileSizes = new TreeMap<>(); + for (File file : mStoreDir.listFiles()) { + final long fileSize = file.length(); + totalSize += fileSize; + if (file.getName().endsWith(SNAPSHOT_FILE_EXTENSION)) { + mFileSizes.put(file, fileSize); + } + } + + while (totalSize > mMaxStorageBytes) { + final Map.Entry entry = mFileSizes.firstEntry(); + if (entry == null) { + break; + } + + File file = entry.getKey(); + if (!file.delete()) { + Slog.e(TAG, "Cannot delete battery usage stats " + file); + } + totalSize -= entry.getValue(); + mFileSizes.remove(file); + } + } +} diff --git a/core/tests/coretests/src/com/android/internal/os/BatteryStatsTests.java b/core/tests/coretests/src/com/android/internal/os/BatteryStatsTests.java index 46e2772b30cad..90a9572b55608 100644 --- a/core/tests/coretests/src/com/android/internal/os/BatteryStatsTests.java +++ b/core/tests/coretests/src/com/android/internal/os/BatteryStatsTests.java @@ -44,6 +44,7 @@ import org.junit.runners.Suite; BatteryStatsUidTest.class, BatteryUsageStatsProviderTest.class, BatteryUsageStatsTest.class, + BatteryUsageStatsStoreTest.class, BatteryStatsUserLifecycleTests.class, BluetoothPowerCalculatorTest.class, BstatsCpuTimesValidationTest.class, diff --git a/core/tests/coretests/src/com/android/internal/os/BatteryUsageStatsStoreTest.java b/core/tests/coretests/src/com/android/internal/os/BatteryUsageStatsStoreTest.java new file mode 100644 index 0000000000000..7570a8e32de44 --- /dev/null +++ b/core/tests/coretests/src/com/android/internal/os/BatteryUsageStatsStoreTest.java @@ -0,0 +1,183 @@ +/* + * Copyright (C) 2021 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.os; + +import static com.google.common.truth.Truth.assertThat; + +import android.content.Context; +import android.os.BatteryManager; +import android.os.BatteryUsageStats; +import android.os.BatteryUsageStatsQuery; +import android.os.Handler; +import android.os.Looper; +import android.os.Message; +import android.util.TypedXmlSerializer; +import android.util.Xml; + +import androidx.test.InstrumentationRegistry; +import androidx.test.runner.AndroidJUnit4; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; + +@RunWith(AndroidJUnit4.class) +public class BatteryUsageStatsStoreTest { + private static final long MAX_BATTERY_STATS_SNAPSHOT_STORAGE_BYTES = 2 * 1024; + + private final MockClocks mMockClocks = new MockClocks(); + private MockBatteryStatsImpl mBatteryStats; + private BatteryUsageStatsStore mBatteryUsageStatsStore; + private BatteryUsageStatsProvider mBatteryUsageStatsProvider; + private File mStoreDirectory; + + @Before + public void setup() { + mMockClocks.currentTime = 123; + mBatteryStats = new MockBatteryStatsImpl(mMockClocks); + mBatteryStats.setNoAutoReset(true); + + Context context = InstrumentationRegistry.getContext(); + + mStoreDirectory = new File(context.getCacheDir(), "BatteryUsageStatsStoreTest"); + clearDirectory(mStoreDirectory); + + mBatteryUsageStatsStore = new BatteryUsageStatsStore(context, mBatteryStats, + mStoreDirectory, new TestHandler(), MAX_BATTERY_STATS_SNAPSHOT_STORAGE_BYTES); + + mBatteryUsageStatsProvider = new BatteryUsageStatsProvider(context, mBatteryStats); + } + + @Test + public void testStoreSnapshot() { + mMockClocks.currentTime = 1_600_000; + + prepareBatteryStats(); + mBatteryStats.resetAllStatsCmdLocked(); + + final long[] timestamps = mBatteryUsageStatsStore.listBatteryUsageStatsTimestamps(); + assertThat(timestamps).hasLength(1); + assertThat(timestamps[0]).isEqualTo(1_600_000); + + final BatteryUsageStats batteryUsageStats = mBatteryUsageStatsStore.loadBatteryUsageStats( + 1_600_000); + assertThat(batteryUsageStats.getStatsStartTimestamp()).isEqualTo(123); + assertThat(batteryUsageStats.getStatsEndTimestamp()).isEqualTo(1_600_000); + assertThat(batteryUsageStats.getBatteryCapacity()).isEqualTo(4000); + assertThat(batteryUsageStats.getDischargePercentage()).isEqualTo(5); + assertThat(batteryUsageStats.getAggregateBatteryConsumer( + BatteryUsageStats.AGGREGATE_BATTERY_CONSUMER_SCOPE_DEVICE).getConsumedPower()) + .isEqualTo(600); // (3_600_000 - 3_000_000) / 1000 + } + + @Test + public void testGarbageCollectOldSnapshots() throws Exception { + prepareBatteryStats(); + + mMockClocks.realtime = 10_000_000; + mMockClocks.uptime = 10_000_000; + mMockClocks.currentTime = 10_000_000; + + final int snapshotFileSize = getSnapshotFileSize(); + final int numberOfSnapshots = + (int) (MAX_BATTERY_STATS_SNAPSHOT_STORAGE_BYTES / snapshotFileSize); + for (int i = 0; i < numberOfSnapshots + 2; i++) { + mBatteryStats.resetAllStatsCmdLocked(); + + mMockClocks.realtime += 10_000_000; + mMockClocks.uptime += 10_000_000; + mMockClocks.currentTime += 10_000_000; + prepareBatteryStats(); + } + + final long[] timestamps = mBatteryUsageStatsStore.listBatteryUsageStatsTimestamps(); + Arrays.sort(timestamps); + assertThat(timestamps).hasLength(numberOfSnapshots); + // Two snapshots (10_000_000 and 20_000_000) should have been discarded + assertThat(timestamps[0]).isEqualTo(30_000_000); + assertThat(getDirectorySize(mStoreDirectory)) + .isAtMost(MAX_BATTERY_STATS_SNAPSHOT_STORAGE_BYTES); + } + + private void prepareBatteryStats() { + mBatteryStats.setBatteryStateLocked(BatteryManager.BATTERY_STATUS_DISCHARGING, 100, + /* plugType */ 0, 90, 72, 3700, 3_600_000, 4_000_000, 0, + mMockClocks.realtime, mMockClocks.uptime, mMockClocks.currentTime); + mBatteryStats.setBatteryStateLocked(BatteryManager.BATTERY_STATUS_DISCHARGING, 100, + /* plugType */ 0, 85, 72, 3700, 3_000_000, 4_000_000, 0, + mMockClocks.realtime + 500_000, mMockClocks.uptime + 500_000, + mMockClocks.currentTime + 500_000); + } + + private void clearDirectory(File dir) { + if (dir.exists()) { + for (File child : dir.listFiles()) { + if (child.isDirectory()) { + clearDirectory(child); + } + child.delete(); + } + } + } + + private long getDirectorySize(File dir) { + long size = 0; + if (dir.exists()) { + for (File child : dir.listFiles()) { + if (child.isDirectory()) { + size += getDirectorySize(child); + } else { + size += child.length(); + } + } + } + return size; + } + + private int getSnapshotFileSize() throws IOException { + BatteryUsageStats stats = mBatteryUsageStatsProvider.getBatteryUsageStats( + new BatteryUsageStatsQuery.Builder() + .setMaxStatsAgeMs(0) + .includePowerModels() + .build()); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + TypedXmlSerializer serializer = Xml.newBinarySerializer(); + serializer.setOutput(out, StandardCharsets.UTF_8.name()); + serializer.startDocument(null, true); + stats.writeXml(serializer); + serializer.endDocument(); + return out.toByteArray().length; + } + + private static class TestHandler extends Handler { + TestHandler() { + super(Looper.getMainLooper()); + } + + @Override + public boolean sendMessageAtTime(Message msg, long uptimeMillis) { + msg.getCallback().run(); + return true; + } + } +} diff --git a/services/core/java/com/android/server/am/BatteryStatsService.java b/services/core/java/com/android/server/am/BatteryStatsService.java index 4e6e91ac7b5d3..94106bc3e1990 100644 --- a/services/core/java/com/android/server/am/BatteryStatsService.java +++ b/services/core/java/com/android/server/am/BatteryStatsService.java @@ -78,6 +78,7 @@ import com.android.internal.os.BackgroundThread; import com.android.internal.os.BatteryStatsHelper; import com.android.internal.os.BatteryStatsImpl; import com.android.internal.os.BatteryUsageStatsProvider; +import com.android.internal.os.BatteryUsageStatsStore; import com.android.internal.os.BinderCallsStats; import com.android.internal.os.PowerProfile; import com.android.internal.os.RailStats; @@ -124,10 +125,12 @@ public final class BatteryStatsService extends IBatteryStats.Stub Watchdog.Monitor { static final String TAG = "BatteryStatsService"; static final boolean DBG = false; + private static final boolean BATTERY_USAGE_STORE_ENABLED = false; private static IBatteryStats sService; final BatteryStatsImpl mStats; + private final BatteryUsageStatsStore mBatteryUsageStatsStore; private final BatteryStatsImpl.UserInfoProvider mUserManagerUserInfoProvider; private final Context mContext; private final BatteryExternalStatsWorker mWorker; @@ -341,6 +344,12 @@ public final class BatteryStatsService extends IBatteryStats.Stub mStats = new BatteryStatsImpl(systemDir, handler, this, this, mUserManagerUserInfoProvider); + if (BATTERY_USAGE_STORE_ENABLED) { + mBatteryUsageStatsStore = + new BatteryUsageStatsStore(context, mStats, systemDir, mHandler); + } else { + mBatteryUsageStatsStore = null; + } mWorker = new BatteryExternalStatsWorker(context, mStats); mStats.setExternalStatsSyncLocked(mWorker); mStats.setRadioScanningTimeoutLocked(mContext.getResources().getInteger(