diff --git a/src/com/android/settings/fuelgauge/batteryusage/BatteryLevelData.java b/src/com/android/settings/fuelgauge/batteryusage/BatteryLevelData.java index 94d7c0e411a..64f519ab9b3 100644 --- a/src/com/android/settings/fuelgauge/batteryusage/BatteryLevelData.java +++ b/src/com/android/settings/fuelgauge/batteryusage/BatteryLevelData.java @@ -16,58 +16,66 @@ package com.android.settings.fuelgauge.batteryusage; -import android.util.Log; - -import com.google.common.collect.ImmutableList; +import androidx.annotation.NonNull; +import androidx.core.util.Preconditions; import java.util.List; /** Wraps the battery timestamp and level data used for battery usage chart. */ public final class BatteryLevelData { - /** A container for the battery timestamp and level data. */ public static final class PeriodBatteryLevelData { - private static final String TAG = "PeriodBatteryLevelData"; + // The length of mTimestamps and mLevels must be the same. mLevels[index] might be null when + // there is no level data for the corresponding timestamp. + private final List mTimestamps; + private final List mLevels; - private final ImmutableList mTimestamps; - private final ImmutableList mLevels; - - public PeriodBatteryLevelData(List timestamps, List levels) { - if (timestamps.size() != levels.size()) { - Log.e(TAG, "Different sizes of timestamps and levels. Timestamp: " - + timestamps.size() + ", Level: " + levels.size()); - mTimestamps = ImmutableList.of(); - mLevels = ImmutableList.of(); - return; - } - mTimestamps = ImmutableList.copyOf(timestamps); - mLevels = ImmutableList.copyOf(levels); + public PeriodBatteryLevelData( + @NonNull List timestamps, @NonNull List levels) { + Preconditions.checkArgument(timestamps.size() == levels.size(), + /* errorMessage= */ "Timestamp: " + timestamps.size() + ", Level: " + + levels.size()); + mTimestamps = timestamps; + mLevels = levels; } - public ImmutableList getTimestamps() { + public List getTimestamps() { return mTimestamps; } - public ImmutableList getLevels() { + public List getLevels() { return mLevels; } } + /** + * There could be 2 cases for the daily battery levels: + * 1) length is 2: The usage data is within 1 day. Only contains start and end data, such as + * data of 2022-01-01 06:00 and 2022-01-01 16:00. + * 2) length > 2: The usage data is more than 1 days. The data should be the start, end and 0am + * data of every day between the start and end, such as data of 2022-01-01 06:00, + * 2022-01-02 00:00, 2022-01-03 00:00 and 2022-01-03 08:00. + */ private final PeriodBatteryLevelData mDailyBatteryLevels; - private final ImmutableList mHourlyBatteryLevelsPerDay; + // The size of hourly data must be the size of daily data - 1. + private final List mHourlyBatteryLevelsPerDay; public BatteryLevelData( - PeriodBatteryLevelData dailyBatteryLevels, - List hourlyBatteryLevelsPerDay) { + @NonNull PeriodBatteryLevelData dailyBatteryLevels, + @NonNull List hourlyBatteryLevelsPerDay) { + final long dailySize = dailyBatteryLevels.getTimestamps().size(); + final long hourlySize = hourlyBatteryLevelsPerDay.size(); + Preconditions.checkArgument(hourlySize == dailySize - 1, + /* errorMessage= */ "DailySize: " + dailySize + ", HourlySize: " + hourlySize); mDailyBatteryLevels = dailyBatteryLevels; - mHourlyBatteryLevelsPerDay = ImmutableList.copyOf(hourlyBatteryLevelsPerDay); + mHourlyBatteryLevelsPerDay = hourlyBatteryLevelsPerDay; } public PeriodBatteryLevelData getDailyBatteryLevels() { return mDailyBatteryLevels; } - public ImmutableList getHourlyBatteryLevelsPerDay() { + public List getHourlyBatteryLevelsPerDay() { return mHourlyBatteryLevelsPerDay; } -} +} \ No newline at end of file diff --git a/src/com/android/settings/fuelgauge/batteryusage/DataProcessor.java b/src/com/android/settings/fuelgauge/batteryusage/DataProcessor.java new file mode 100644 index 00000000000..d8650a651eb --- /dev/null +++ b/src/com/android/settings/fuelgauge/batteryusage/DataProcessor.java @@ -0,0 +1,468 @@ +/* + * Copyright (C) 2022 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.settings.fuelgauge.batteryusage; + +import static com.android.settings.fuelgauge.batteryusage.ConvertUtils.utcToLocalTime; + +import android.content.Context; +import android.text.format.DateUtils; +import android.util.Log; + +import androidx.annotation.Nullable; + +import com.android.internal.annotations.VisibleForTesting; +import com.android.settingslib.fuelgauge.BatteryStatus; + +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * A utility class to process data loaded from database and make the data easy to use for battery + * usage UI. + */ +public final class DataProcessor { + private static final boolean DEBUG = false; + private static final String TAG = "DataProcessor"; + private static final int MIN_DAILY_DATA_SIZE = 2; + private static final int MIN_TIMESTAMP_DATA_SIZE = 2; + + /** A fake package name to represent no BatteryEntry data. */ + public static final String FAKE_PACKAGE_NAME = "fake_package"; + + private DataProcessor() { + } + + /** + * @return Returns battery level data and start async task to compute battery diff usage data + * and load app labels + icons. + * Returns null if the input is invalid or not having at least 2 hours data. + */ + @Nullable + public static BatteryLevelData getBatteryLevelData( + Context context, + @Nullable final Map> batteryHistoryMap) { + if (batteryHistoryMap == null || batteryHistoryMap.isEmpty()) { + Log.d(TAG, "getBatteryLevelData() returns null"); + return null; + } + // Process raw history map data into hourly timestamps. + final Map> processedBatteryHistoryMap = + getHistoryMapWithExpectedTimestamps(context, batteryHistoryMap); + // Wrap and processed history map into easy-to-use format for UI rendering. + final BatteryLevelData batteryLevelData = + getLevelDataThroughProcessedHistoryMap(context, processedBatteryHistoryMap); + + //TODO: Add the async task to compute diff usage data and load labels and icons. + + return batteryLevelData; + } + + /** + * @return Returns the processed history map which has interpolated to every hour data. + * The start and end timestamp must be the even hours. + * The keys of processed history map should contain every hour between the start and end + * timestamp. If there's no data in some key, the value will be the empty hashmap. + */ + @VisibleForTesting + static Map> getHistoryMapWithExpectedTimestamps( + Context context, + final Map> batteryHistoryMap) { + final long startTime = System.currentTimeMillis(); + final List rawTimestampList = new ArrayList<>(batteryHistoryMap.keySet()); + final Map> resultMap = new HashMap(); + if (rawTimestampList.isEmpty()) { + Log.d(TAG, "empty batteryHistoryMap in getHistoryMapWithExpectedTimestamps()"); + return resultMap; + } + Collections.sort(rawTimestampList); + final List expectedTimestampList = getTimestampSlots(rawTimestampList); + final boolean isFromFullCharge = + isFromFullCharge(batteryHistoryMap.get(rawTimestampList.get(0))); + interpolateHistory( + context, rawTimestampList, expectedTimestampList, isFromFullCharge, + batteryHistoryMap, resultMap); + Log.d(TAG, String.format("getHistoryMapWithExpectedTimestamps() size=%d in %d/ms", + resultMap.size(), (System.currentTimeMillis() - startTime))); + return resultMap; + } + + @VisibleForTesting + @Nullable + static BatteryLevelData getLevelDataThroughProcessedHistoryMap( + Context context, + final Map> processedBatteryHistoryMap) { + final List timestampList = new ArrayList<>(processedBatteryHistoryMap.keySet()); + Collections.sort(timestampList); + final List dailyTimestamps = getDailyTimestamps(timestampList); + // There should be at least the start and end timestamps. Otherwise, return null to not show + // data in usage chart. + if (dailyTimestamps.size() < MIN_DAILY_DATA_SIZE) { + return null; + } + + final List> hourlyTimestamps = getHourlyTimestamps(dailyTimestamps); + final BatteryLevelData.PeriodBatteryLevelData dailyLevelData = + getPeriodBatteryLevelData(context, processedBatteryHistoryMap, dailyTimestamps); + final List hourlyLevelData = + getHourlyPeriodBatteryLevelData( + context, processedBatteryHistoryMap, hourlyTimestamps); + return new BatteryLevelData(dailyLevelData, hourlyLevelData); + } + + /** + * Computes expected timestamp slots for last full charge, which will return hourly timestamps + * between start and end two even hour values. + */ + @VisibleForTesting + static List getTimestampSlots(final List rawTimestampList) { + final List timestampSlots = new ArrayList<>(); + final int rawTimestampListSize = rawTimestampList.size(); + // If timestamp number is smaller than 2, the following computation is not necessary. + if (rawTimestampListSize < MIN_TIMESTAMP_DATA_SIZE) { + return timestampSlots; + } + final long rawStartTimestamp = rawTimestampList.get(0); + final long rawEndTimestamp = rawTimestampList.get(rawTimestampListSize - 1); + // No matter the start is from last full charge or 6 days ago, use the nearest even hour. + final long startTimestamp = getNearestEvenHourTimestamp(rawStartTimestamp); + // Use the even hour before the raw end timestamp as the end. + final long endTimestamp = getLastEvenHourBeforeTimestamp(rawEndTimestamp); + // If the start timestamp is later or equal the end one, return the empty list. + if (startTimestamp >= endTimestamp) { + return timestampSlots; + } + for (long timestamp = startTimestamp; timestamp <= endTimestamp; + timestamp += DateUtils.HOUR_IN_MILLIS) { + timestampSlots.add(timestamp); + } + return timestampSlots; + } + + /** + * Computes expected daily timestamp slots. + * + * The valid result should be composed of 3 parts: + * 1) start timestamp + * 2) every 0am timestamp (default timezone) between the start and end + * 3) end timestamp + * Otherwise, returns an empty list. + */ + @VisibleForTesting + static List getDailyTimestamps(final List timestampList) { + final List dailyTimestampList = new ArrayList<>(); + // If timestamp number is smaller than 2, the following computation is not necessary. + if (timestampList.size() < MIN_TIMESTAMP_DATA_SIZE) { + return dailyTimestampList; + } + final long startTime = timestampList.get(0); + final long endTime = timestampList.get(timestampList.size() - 1); + long nextDay = getTimestampOfNextDay(startTime); + dailyTimestampList.add(startTime); + while (nextDay < endTime) { + dailyTimestampList.add(nextDay); + nextDay += DateUtils.DAY_IN_MILLIS; + } + dailyTimestampList.add(endTime); + return dailyTimestampList; + } + + @VisibleForTesting + static boolean isFromFullCharge(@Nullable final Map entryList) { + if (entryList == null) { + Log.d(TAG, "entryList is nul in isFromFullCharge()"); + return false; + } + final List entryKeys = new ArrayList<>(entryList.keySet()); + if (entryKeys.isEmpty()) { + Log.d(TAG, "empty entryList in isFromFullCharge()"); + return false; + } + // The hist entries in the same timestamp should have same battery status and level. + // Checking the first one should be enough. + final BatteryHistEntry firstHistEntry = entryList.get(entryKeys.get(0)); + return BatteryStatus.isCharged(firstHistEntry.mBatteryStatus, firstHistEntry.mBatteryLevel); + } + + @VisibleForTesting + static long[] findNearestTimestamp(final List timestamps, final long target) { + final long[] results = new long[] {Long.MIN_VALUE, Long.MAX_VALUE}; + // Searches the nearest lower and upper timestamp value. + for (long timestamp : timestamps) { + if (timestamp <= target && timestamp > results[0]) { + results[0] = timestamp; + } + if (timestamp >= target && timestamp < results[1]) { + results[1] = timestamp; + } + } + // Uses zero value to represent invalid searching result. + results[0] = results[0] == Long.MIN_VALUE ? 0 : results[0]; + results[1] = results[1] == Long.MAX_VALUE ? 0 : results[1]; + return results; + } + + /** + * @return Returns the timestamp for 0am 1 day after the given timestamp based on local + * timezone. + */ + @VisibleForTesting + static long getTimestampOfNextDay(long timestamp) { + final Calendar nextDayCalendar = Calendar.getInstance(); + nextDayCalendar.setTimeInMillis(timestamp); + nextDayCalendar.add(Calendar.DAY_OF_YEAR, 1); + nextDayCalendar.set(Calendar.HOUR_OF_DAY, 0); + nextDayCalendar.set(Calendar.MINUTE, 0); + nextDayCalendar.set(Calendar.SECOND, 0); + return nextDayCalendar.getTimeInMillis(); + } + + /** + * Interpolates history map based on expected timestamp slots and processes the corner case when + * the expected start timestamp is earlier than what we have. + */ + private static void interpolateHistory( + Context context, + final List rawTimestampList, + final List expectedTimestampSlots, + final boolean isFromFullCharge, + final Map> batteryHistoryMap, + final Map> resultMap) { + if (rawTimestampList.isEmpty() || expectedTimestampSlots.isEmpty()) { + return; + } + final long expectedStartTimestamp = expectedTimestampSlots.get(0); + final long rawStartTimestamp = rawTimestampList.get(0); + int startIndex = 0; + // If the expected start timestamp is full charge or earlier than what we have, use the + // first data of what we have directly. This should be OK because the expected start + // timestamp is the nearest even hour of the raw start timestamp, their time diff is no + // more than 1 hour. + if (isFromFullCharge || expectedStartTimestamp < rawStartTimestamp) { + startIndex = 1; + resultMap.put(expectedStartTimestamp, batteryHistoryMap.get(rawStartTimestamp)); + } + for (int index = startIndex; index < expectedTimestampSlots.size(); index++) { + final long currentSlot = expectedTimestampSlots.get(index); + interpolateHistoryForSlot( + context, currentSlot, rawTimestampList, batteryHistoryMap, resultMap); + } + } + + private static void interpolateHistoryForSlot( + Context context, + final long currentSlot, + final List rawTimestampList, + final Map> batteryHistoryMap, + final Map> resultMap) { + final long[] nearestTimestamps = findNearestTimestamp(rawTimestampList, currentSlot); + final long lowerTimestamp = nearestTimestamps[0]; + final long upperTimestamp = nearestTimestamps[1]; + // Case 1: upper timestamp is zero since scheduler is delayed! + if (upperTimestamp == 0) { + log(context, "job scheduler is delayed", currentSlot, null); + resultMap.put(currentSlot, new HashMap<>()); + return; + } + // Case 2: upper timestamp is closed to the current timestamp. + if ((upperTimestamp - currentSlot) < 5 * DateUtils.SECOND_IN_MILLIS) { + log(context, "force align into the nearest slot", currentSlot, null); + resultMap.put(currentSlot, batteryHistoryMap.get(upperTimestamp)); + return; + } + // Case 3: lower timestamp is zero before starting to collect data. + if (lowerTimestamp == 0) { + log(context, "no lower timestamp slot data", currentSlot, null); + resultMap.put(currentSlot, new HashMap<>()); + return; + } + interpolateHistoryForSlot(context, + currentSlot, lowerTimestamp, upperTimestamp, batteryHistoryMap, resultMap); + } + + private static void interpolateHistoryForSlot( + Context context, + final long currentSlot, + final long lowerTimestamp, + final long upperTimestamp, + final Map> batteryHistoryMap, + final Map> resultMap) { + final Map lowerEntryDataMap = + batteryHistoryMap.get(lowerTimestamp); + final Map upperEntryDataMap = + batteryHistoryMap.get(upperTimestamp); + // Verifies whether the lower data is valid to use or not by checking boot time. + final BatteryHistEntry upperEntryDataFirstEntry = + upperEntryDataMap.values().stream().findFirst().get(); + final long upperEntryDataBootTimestamp = + upperEntryDataFirstEntry.mTimestamp - upperEntryDataFirstEntry.mBootTimestamp; + // Lower data is captured before upper data corresponding device is booting. + if (lowerTimestamp < upperEntryDataBootTimestamp) { + // Provides an opportunity to force align the slot directly. + if ((upperTimestamp - currentSlot) < 10 * DateUtils.MINUTE_IN_MILLIS) { + log(context, "force align into the nearest slot", currentSlot, null); + resultMap.put(currentSlot, upperEntryDataMap); + } else { + log(context, "in the different booting section", currentSlot, null); + resultMap.put(currentSlot, new HashMap<>()); + } + return; + } + log(context, "apply interpolation arithmetic", currentSlot, null); + final Map newHistEntryMap = new HashMap<>(); + final double timestampLength = upperTimestamp - lowerTimestamp; + final double timestampDiff = currentSlot - lowerTimestamp; + // Applies interpolation arithmetic for each BatteryHistEntry. + for (String entryKey : upperEntryDataMap.keySet()) { + final BatteryHistEntry lowerEntry = lowerEntryDataMap.get(entryKey); + final BatteryHistEntry upperEntry = upperEntryDataMap.get(entryKey); + // Checks whether there is any abnormal battery reset conditions. + if (lowerEntry != null) { + final boolean invalidForegroundUsageTime = + lowerEntry.mForegroundUsageTimeInMs > upperEntry.mForegroundUsageTimeInMs; + final boolean invalidBackgroundUsageTime = + lowerEntry.mBackgroundUsageTimeInMs > upperEntry.mBackgroundUsageTimeInMs; + if (invalidForegroundUsageTime || invalidBackgroundUsageTime) { + newHistEntryMap.put(entryKey, upperEntry); + log(context, "abnormal reset condition is found", currentSlot, upperEntry); + continue; + } + } + final BatteryHistEntry newEntry = + BatteryHistEntry.interpolate( + currentSlot, + upperTimestamp, + /*ratio=*/ timestampDiff / timestampLength, + lowerEntry, + upperEntry); + newHistEntryMap.put(entryKey, newEntry); + if (lowerEntry == null) { + log(context, "cannot find lower entry data", currentSlot, upperEntry); + continue; + } + } + resultMap.put(currentSlot, newHistEntryMap); + } + + /** + * @return Returns the nearest even hour timestamp of the given timestamp. + */ + private static long getNearestEvenHourTimestamp(long rawTimestamp) { + // If raw hour is even, the nearest even hour should be the even hour before raw + // start. The hour doesn't need to change and just set the minutes and seconds to 0. + // Otherwise, the nearest even hour should be raw hour + 1. + // For example, the nearest hour of 14:30:50 should be 14:00:00. While the nearest + // hour of 15:30:50 should be 16:00:00. + return getEvenHourTimestamp(rawTimestamp, /*addHourOfDay*/ 1); + } + + /** + * @return Returns the last even hour timestamp before the given timestamp. + */ + private static long getLastEvenHourBeforeTimestamp(long rawTimestamp) { + // If raw hour is even, the hour doesn't need to change as well. + // Otherwise, the even hour before raw end should be raw hour - 1. + // For example, the even hour before 14:30:50 should be 14:00:00. While the even + // hour before 15:30:50 should be 14:00:00. + return getEvenHourTimestamp(rawTimestamp, /*addHourOfDay*/ -1); + } + + private static long getEvenHourTimestamp(long rawTimestamp, int addHourOfDay) { + final Calendar evenHourCalendar = Calendar.getInstance(); + evenHourCalendar.setTimeInMillis(rawTimestamp); + // Before computing the evenHourCalendar, record raw hour based on local timezone. + final int rawHour = evenHourCalendar.get(Calendar.HOUR_OF_DAY); + if (rawHour % 2 != 0) { + evenHourCalendar.add(Calendar.HOUR_OF_DAY, addHourOfDay); + } + evenHourCalendar.set(Calendar.MINUTE, 0); + evenHourCalendar.set(Calendar.SECOND, 0); + evenHourCalendar.set(Calendar.MILLISECOND, 0); + return evenHourCalendar.getTimeInMillis(); + } + + private static List> getHourlyTimestamps(final List dailyTimestamps) { + final List> hourlyTimestamps = new ArrayList<>(); + if (dailyTimestamps.size() < MIN_DAILY_DATA_SIZE) { + return hourlyTimestamps; + } + + for (int dailyStartIndex = 0; dailyStartIndex < dailyTimestamps.size() - 1; + dailyStartIndex++) { + long currentTimestamp = dailyTimestamps.get(dailyStartIndex); + final long dailyEndTimestamp = dailyTimestamps.get(dailyStartIndex + 1); + final List hourlyTimestampsPerDay = new ArrayList<>(); + while (currentTimestamp <= dailyEndTimestamp) { + hourlyTimestampsPerDay.add(currentTimestamp); + currentTimestamp += 2 * DateUtils.HOUR_IN_MILLIS; + } + hourlyTimestamps.add(hourlyTimestampsPerDay); + } + return hourlyTimestamps; + } + + private static List getHourlyPeriodBatteryLevelData( + Context context, + final Map> processedBatteryHistoryMap, + final List> timestamps) { + final List levelData = new ArrayList<>(); + timestamps.forEach( + timestampList -> levelData.add( + getPeriodBatteryLevelData( + context, processedBatteryHistoryMap, timestampList))); + return levelData; + } + + private static BatteryLevelData.PeriodBatteryLevelData getPeriodBatteryLevelData( + Context context, + final Map> processedBatteryHistoryMap, + final List timestamps) { + final List levels = new ArrayList<>(); + timestamps.forEach( + timestamp -> levels.add(getLevel(context, processedBatteryHistoryMap, timestamp))); + return new BatteryLevelData.PeriodBatteryLevelData(timestamps, levels); + } + + private static Integer getLevel( + Context context, + final Map> processedBatteryHistoryMap, + final long timestamp) { + final Map entryMap = processedBatteryHistoryMap.get(timestamp); + if (entryMap == null || entryMap.isEmpty()) { + Log.e(TAG, "abnormal entry list in the timestamp:" + + utcToLocalTime(context, timestamp)); + return null; + } + // Averages the battery level in each time slot to avoid corner conditions. + float batteryLevelCounter = 0; + for (BatteryHistEntry entry : entryMap.values()) { + batteryLevelCounter += entry.mBatteryLevel; + } + return Math.round(batteryLevelCounter / entryMap.size()); + } + + private static void log(Context context, String content, long timestamp, + BatteryHistEntry entry) { + if (DEBUG) { + Log.d(TAG, String.format(entry != null ? "%s %s:\n%s" : "%s %s:%s", + utcToLocalTime(context, timestamp), content, entry)); + } + } +} diff --git a/tests/robotests/src/com/android/settings/fuelgauge/batteryusage/DataProcessorTest.java b/tests/robotests/src/com/android/settings/fuelgauge/batteryusage/DataProcessorTest.java new file mode 100644 index 00000000000..0306c4b2afa --- /dev/null +++ b/tests/robotests/src/com/android/settings/fuelgauge/batteryusage/DataProcessorTest.java @@ -0,0 +1,454 @@ +/* + * Copyright (C) 2022 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.settings.fuelgauge.batteryusage; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.Mockito.spy; + +import android.content.ContentValues; +import android.content.Context; +import android.text.format.DateUtils; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.MockitoAnnotations; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Calendar; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.TimeZone; + +@RunWith(RobolectricTestRunner.class) +public class DataProcessorTest { + private static final String FAKE_ENTRY_KEY = "fake_entry_key"; + + private Context mContext; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + TimeZone.setDefault(TimeZone.getTimeZone("GMT+8")); + + mContext = spy(RuntimeEnvironment.application); + } + + @Test + public void getBatteryLevelData_emptyHistoryMap_returnNull() { + assertThat(DataProcessor.getBatteryLevelData(mContext, null)).isNull(); + assertThat(DataProcessor.getBatteryLevelData(mContext, new HashMap<>())).isNull(); + } + + @Test + public void getBatteryLevelData_notEnoughData_returnNull() { + // The timestamps are within 1 hour. + final long[] timestamps = {1000000L, 2000000L, 3000000L}; + final int[] levels = {100, 99, 98}; + final Map> batteryHistoryMap = + createHistoryMap(timestamps, levels); + + assertThat(DataProcessor.getBatteryLevelData(mContext, batteryHistoryMap)).isNull(); + } + + @Test + public void getBatteryLevelData_returnExpectedResult() { + // Timezone GMT+8: 2022-01-01 00:00:00, 2022-01-01 01:00:00, 2022-01-01 02:00:00 + final long[] timestamps = {1640966400000L, 1640970000000L, 1640973600000L}; + final int[] levels = {100, 99, 98}; + final Map> batteryHistoryMap = + createHistoryMap(timestamps, levels); + + final BatteryLevelData resultData = + DataProcessor.getBatteryLevelData(mContext, batteryHistoryMap); + + final List expectedDailyTimestamps = List.of(timestamps[0], timestamps[2]); + final List expectedDailyLevels = List.of(levels[0], levels[2]); + final List> expectedHourlyTimestamps = List.of(expectedDailyTimestamps); + final List> expectedHourlyLevels = List.of(expectedDailyLevels); + verifyExpectedBatteryLevelData( + resultData, + expectedDailyTimestamps, + expectedDailyLevels, + expectedHourlyTimestamps, + expectedHourlyLevels); + } + + @Test + public void getHistoryMapWithExpectedTimestamps_emptyHistoryMap_returnEmptyMap() { + assertThat(DataProcessor + .getHistoryMapWithExpectedTimestamps(mContext, new HashMap<>())) + .isEmpty(); + } + + @Test + public void getHistoryMapWithExpectedTimestamps_returnExpectedMap() { + // Timezone GMT+8 + final long[] timestamps = { + 1640966700000L, // 2022-01-01 00:05:00 + 1640970180000L, // 2022-01-01 01:03:00 + 1640973840000L, // 2022-01-01 02:04:00 + 1640978100000L, // 2022-01-01 03:15:00 + 1640981400000L // 2022-01-01 04:10:00 + }; + final int[] levels = {100, 94, 90, 82, 50}; + final Map> batteryHistoryMap = + createHistoryMap(timestamps, levels); + + final Map> resultMap = + DataProcessor.getHistoryMapWithExpectedTimestamps(mContext, batteryHistoryMap); + + // Timezone GMT+8 + final long[] expectedTimestamps = { + 1640966400000L, // 2022-01-01 00:00:00 + 1640970000000L, // 2022-01-01 01:00:00 + 1640973600000L, // 2022-01-01 02:00:00 + 1640977200000L, // 2022-01-01 03:00:00 + 1640980800000L // 2022-01-01 04:00:00 + }; + final int[] expectedLevels = {100, 94, 90, 84, 56}; + assertThat(resultMap).hasSize(expectedLevels.length); + for (int index = 0; index < expectedLevels.length; index++) { + assertThat(resultMap.get(expectedTimestamps[index]).get(FAKE_ENTRY_KEY).mBatteryLevel) + .isEqualTo(expectedLevels[index]); + } + } + + @Test + public void getLevelDataThroughProcessedHistoryMap_notEnoughData_returnNull() { + final long[] timestamps = {100L}; + final int[] levels = {100}; + final Map> batteryHistoryMap = + createHistoryMap(timestamps, levels); + + assertThat( + DataProcessor.getLevelDataThroughProcessedHistoryMap(mContext, batteryHistoryMap)) + .isNull(); + } + + @Test + public void getLevelDataThroughProcessedHistoryMap_OneDayData_returnExpectedResult() { + // Timezone GMT+8 + final long[] timestamps = { + 1640966400000L, // 2022-01-01 00:00:00 + 1640970000000L, // 2022-01-01 01:00:00 + 1640973600000L, // 2022-01-01 02:00:00 + 1640977200000L, // 2022-01-01 03:00:00 + 1640980800000L // 2022-01-01 04:00:00 + }; + final int[] levels = {100, 94, 90, 82, 50}; + final Map> batteryHistoryMap = + createHistoryMap(timestamps, levels); + + final BatteryLevelData resultData = + DataProcessor.getLevelDataThroughProcessedHistoryMap(mContext, batteryHistoryMap); + + final List expectedDailyTimestamps = List.of(timestamps[0], timestamps[4]); + final List expectedDailyLevels = List.of(levels[0], levels[4]); + final List> expectedHourlyTimestamps = List.of( + List.of(timestamps[0], timestamps[2], timestamps[4]) + ); + final List> expectedHourlyLevels = List.of( + List.of(levels[0], levels[2], levels[4]) + ); + verifyExpectedBatteryLevelData( + resultData, + expectedDailyTimestamps, + expectedDailyLevels, + expectedHourlyTimestamps, + expectedHourlyLevels); + } + + @Test + public void getLevelDataThroughProcessedHistoryMap_MultipleDaysData_returnExpectedResult() { + // Timezone GMT+8 + final long[] timestamps = { + 1641038400000L, // 2022-01-01 20:00:00 + 1641060000000L, // 2022-01-02 02:00:00 + 1641067200000L, // 2022-01-02 04:00:00 + 1641081600000L, // 2022-01-02 08:00:00 + }; + final int[] levels = {100, 94, 90, 82}; + final Map> batteryHistoryMap = + createHistoryMap(timestamps, levels); + + final BatteryLevelData resultData = + DataProcessor.getLevelDataThroughProcessedHistoryMap(mContext, batteryHistoryMap); + + final List expectedDailyTimestamps = List.of( + 1641038400000L, // 2022-01-01 20:00:00 + 1641052800000L, // 2022-01-02 00:00:00 + 1641081600000L // 2022-01-02 08:00:00 + ); + final List expectedDailyLevels = new ArrayList<>(); + expectedDailyLevels.add(100); + expectedDailyLevels.add(null); + expectedDailyLevels.add(82); + final List> expectedHourlyTimestamps = List.of( + List.of( + 1641038400000L, // 2022-01-01 20:00:00 + 1641045600000L, // 2022-01-01 22:00:00 + 1641052800000L // 2022-01-02 00:00:00 + ), + List.of( + 1641052800000L, // 2022-01-02 00:00:00 + 1641060000000L, // 2022-01-02 02:00:00 + 1641067200000L, // 2022-01-02 04:00:00 + 1641074400000L, // 2022-01-02 06:00:00 + 1641081600000L // 2022-01-02 08:00:00 + ) + ); + final List expectedHourlyLevels1 = new ArrayList<>(); + expectedHourlyLevels1.add(100); + expectedHourlyLevels1.add(null); + expectedHourlyLevels1.add(null); + final List expectedHourlyLevels2 = new ArrayList<>(); + expectedHourlyLevels2.add(null); + expectedHourlyLevels2.add(94); + expectedHourlyLevels2.add(90); + expectedHourlyLevels2.add(null); + expectedHourlyLevels2.add(82); + final List> expectedHourlyLevels = List.of( + expectedHourlyLevels1, + expectedHourlyLevels2 + ); + verifyExpectedBatteryLevelData( + resultData, + expectedDailyTimestamps, + expectedDailyLevels, + expectedHourlyTimestamps, + expectedHourlyLevels); + } + + @Test + public void getTimestampSlots_emptyRawList_returnEmptyList() { + final List resultList = + DataProcessor.getTimestampSlots(new ArrayList<>()); + assertThat(resultList).isEmpty(); + } + + @Test + public void getTimestampSlots_startWithEvenHour_returnExpectedResult() { + final Calendar startCalendar = Calendar.getInstance(); + startCalendar.set(2022, 6, 5, 6, 30, 50); // 2022-07-05 06:30:50 + final Calendar endCalendar = Calendar.getInstance(); + endCalendar.set(2022, 6, 5, 22, 30, 50); // 2022-07-05 22:30:50 + + final Calendar expectedStartCalendar = Calendar.getInstance(); + expectedStartCalendar.set(2022, 6, 5, 6, 0, 0); // 2022-07-05 06:00:00 + final Calendar expectedEndCalendar = Calendar.getInstance(); + expectedEndCalendar.set(2022, 6, 5, 22, 0, 0); // 2022-07-05 22:00:00 + verifyExpectedTimestampSlots( + startCalendar, endCalendar, expectedStartCalendar, expectedEndCalendar); + } + + @Test + public void getTimestampSlots_startWithOddHour_returnExpectedResult() { + final Calendar startCalendar = Calendar.getInstance(); + startCalendar.set(2022, 6, 5, 5, 0, 50); // 2022-07-05 05:00:50 + final Calendar endCalendar = Calendar.getInstance(); + endCalendar.set(2022, 6, 6, 21, 00, 50); // 2022-07-06 21:00:50 + + final Calendar expectedStartCalendar = Calendar.getInstance(); + expectedStartCalendar.set(2022, 6, 5, 6, 00, 00); // 2022-07-05 06:00:00 + final Calendar expectedEndCalendar = Calendar.getInstance(); + expectedEndCalendar.set(2022, 6, 6, 20, 00, 00); // 2022-07-06 20:00:00 + verifyExpectedTimestampSlots( + startCalendar, endCalendar, expectedStartCalendar, expectedEndCalendar); + } + + @Test + public void getDailyTimestamps_notEnoughData_returnEmptyList() { + assertThat(DataProcessor.getDailyTimestamps(new ArrayList<>())).isEmpty(); + assertThat(DataProcessor.getDailyTimestamps(List.of(100L))).isEmpty(); + } + + @Test + public void getDailyTimestamps_OneDayData_returnExpectedList() { + // Timezone GMT+8 + final List timestamps = List.of( + 1640966400000L, // 2022-01-01 00:00:00 + 1640970000000L, // 2022-01-01 01:00:00 + 1640973600000L, // 2022-01-01 02:00:00 + 1640977200000L, // 2022-01-01 03:00:00 + 1640980800000L // 2022-01-01 04:00:00 + ); + + final List expectedTimestamps = List.of( + 1640966400000L, // 2022-01-01 00:00:00 + 1640980800000L // 2022-01-01 04:00:00 + ); + assertThat(DataProcessor.getDailyTimestamps(timestamps)).isEqualTo(expectedTimestamps); + } + + @Test + public void getDailyTimestamps_MultipleDaysData_returnExpectedList() { + // Timezone GMT+8 + final List timestamps = List.of( + 1640988000000L, // 2022-01-01 06:00:00 + 1641060000000L, // 2022-01-02 02:00:00 + 1641160800000L, // 2022-01-03 06:00:00 + 1641254400000L // 2022-01-04 08:00:00 + ); + + final List expectedTimestamps = List.of( + 1640988000000L, // 2022-01-01 06:00:00 + 1641052800000L, // 2022-01-02 00:00:00 + 1641139200000L, // 2022-01-03 00:00:00 + 1641225600000L, // 2022-01-04 00:00:00 + 1641254400000L // 2022-01-04 08:00:00 + ); + assertThat(DataProcessor.getDailyTimestamps(timestamps)).isEqualTo(expectedTimestamps); + } + + @Test + public void isFromFullCharge_emptyData_returnFalse() { + assertThat(DataProcessor.isFromFullCharge(null)).isFalse(); + assertThat(DataProcessor.isFromFullCharge(new HashMap<>())).isFalse(); + } + + @Test + public void isFromFullCharge_notChargedData_returnFalse() { + final Map entryMap = new HashMap<>(); + final ContentValues values = new ContentValues(); + values.put("batteryLevel", 98); + final BatteryHistEntry entry = new BatteryHistEntry(values); + entryMap.put(FAKE_ENTRY_KEY, entry); + + assertThat(DataProcessor.isFromFullCharge(entryMap)).isFalse(); + } + + @Test + public void isFromFullCharge_chargedData_returnTrue() { + final Map entryMap = new HashMap<>(); + final ContentValues values = new ContentValues(); + values.put("batteryLevel", 100); + final BatteryHistEntry entry = new BatteryHistEntry(values); + entryMap.put(FAKE_ENTRY_KEY, entry); + + assertThat(DataProcessor.isFromFullCharge(entryMap)).isTrue(); + } + + @Test + public void findNearestTimestamp_returnExpectedResult() { + long[] results = DataProcessor.findNearestTimestamp( + Arrays.asList(10L, 20L, 30L, 40L), /*target=*/ 15L); + assertThat(results).isEqualTo(new long[] {10L, 20L}); + + results = DataProcessor.findNearestTimestamp( + Arrays.asList(10L, 20L, 30L, 40L), /*target=*/ 10L); + assertThat(results).isEqualTo(new long[] {10L, 10L}); + + results = DataProcessor.findNearestTimestamp( + Arrays.asList(10L, 20L, 30L, 40L), /*target=*/ 5L); + assertThat(results).isEqualTo(new long[] {0L, 10L}); + + results = DataProcessor.findNearestTimestamp( + Arrays.asList(10L, 20L, 30L, 40L), /*target=*/ 50L); + assertThat(results).isEqualTo(new long[] {40L, 0L}); + } + + @Test + public void getTimestampOfNextDay_returnExpectedResult() { + // 2021-02-28 06:00:00 => 2021-03-01 00:00:00 + assertThat(DataProcessor.getTimestampOfNextDay(1614463200000L)) + .isEqualTo(1614528000000L); + // 2021-12-31 16:00:00 => 2022-01-01 00:00:00 + assertThat(DataProcessor.getTimestampOfNextDay(1640937600000L)) + .isEqualTo(1640966400000L); + } + + private static Map> createHistoryMap( + final long[] timestamps, final int[] levels) { + final Map> batteryHistoryMap = new HashMap<>(); + for (int index = 0; index < timestamps.length; index++) { + final Map entryMap = new HashMap<>(); + final ContentValues values = new ContentValues(); + values.put(BatteryHistEntry.KEY_BATTERY_LEVEL, levels[index]); + final BatteryHistEntry entry = new BatteryHistEntry(values); + entryMap.put(FAKE_ENTRY_KEY, entry); + batteryHistoryMap.put(timestamps[index], entryMap); + } + return batteryHistoryMap; + } + + private static void verifyExpectedBatteryLevelData( + final BatteryLevelData resultData, + final List expectedDailyTimestamps, + final List expectedDailyLevels, + final List> expectedHourlyTimestamps, + final List> expectedHourlyLevels) { + final BatteryLevelData.PeriodBatteryLevelData dailyResultData = + resultData.getDailyBatteryLevels(); + final List hourlyResultData = + resultData.getHourlyBatteryLevelsPerDay(); + verifyExpectedDailyBatteryLevelData( + dailyResultData, expectedDailyTimestamps, expectedDailyLevels); + verifyExpectedHourlyBatteryLevelData( + hourlyResultData, expectedHourlyTimestamps, expectedHourlyLevels); + } + + private static void verifyExpectedDailyBatteryLevelData( + final BatteryLevelData.PeriodBatteryLevelData dailyResultData, + final List expectedDailyTimestamps, + final List expectedDailyLevels) { + assertThat(dailyResultData.getTimestamps()).isEqualTo(expectedDailyTimestamps); + assertThat(dailyResultData.getLevels()).isEqualTo(expectedDailyLevels); + } + + private static void verifyExpectedHourlyBatteryLevelData( + final List hourlyResultData, + final List> expectedHourlyTimestamps, + final List> expectedHourlyLevels) { + final int expectedHourlySize = expectedHourlyTimestamps.size(); + assertThat(hourlyResultData).hasSize(expectedHourlySize); + for (int dailyIndex = 0; dailyIndex < expectedHourlySize; dailyIndex++) { + assertThat(hourlyResultData.get(dailyIndex).getTimestamps()) + .isEqualTo(expectedHourlyTimestamps.get(dailyIndex)); + assertThat(hourlyResultData.get(dailyIndex).getLevels()) + .isEqualTo(expectedHourlyLevels.get(dailyIndex)); + } + } + + private static void verifyExpectedTimestampSlots( + final Calendar start, + final Calendar end, + final Calendar expectedStart, + final Calendar expectedEnd) { + expectedStart.set(Calendar.MILLISECOND, 0); + expectedEnd.set(Calendar.MILLISECOND, 0); + final ArrayList timestampSlots = new ArrayList<>(); + timestampSlots.add(start.getTimeInMillis()); + timestampSlots.add(end.getTimeInMillis()); + final List resultList = + DataProcessor.getTimestampSlots(timestampSlots); + + for (int index = 0; index < resultList.size(); index++) { + final long expectedTimestamp = + expectedStart.getTimeInMillis() + index * DateUtils.HOUR_IN_MILLIS; + assertThat(resultList.get(index)).isEqualTo(expectedTimestamp); + } + assertThat(resultList.get(resultList.size() - 1)) + .isEqualTo(expectedEnd.getTimeInMillis()); + } + +}