diff --git a/src/com/android/settings/fuelgauge/batteryusage/BatteryChartPreferenceControllerV2.java b/src/com/android/settings/fuelgauge/batteryusage/BatteryChartPreferenceControllerV2.java new file mode 100644 index 00000000000..10f19f9d1bb --- /dev/null +++ b/src/com/android/settings/fuelgauge/batteryusage/BatteryChartPreferenceControllerV2.java @@ -0,0 +1,737 @@ +/* + * 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 android.app.settings.SettingsEnums; +import android.content.Context; +import android.content.res.Configuration; +import android.graphics.drawable.Drawable; +import android.os.AsyncTask; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.text.TextUtils; +import android.text.format.DateFormat; +import android.text.format.DateUtils; +import android.util.Log; + +import androidx.annotation.VisibleForTesting; +import androidx.preference.Preference; +import androidx.preference.PreferenceGroup; +import androidx.preference.PreferenceScreen; + +import com.android.settings.R; +import com.android.settings.SettingsActivity; +import com.android.settings.core.InstrumentedPreferenceFragment; +import com.android.settings.core.PreferenceControllerMixin; +import com.android.settings.fuelgauge.AdvancedPowerUsageDetail; +import com.android.settings.fuelgauge.BatteryUtils; +import com.android.settings.overlay.FeatureFactory; +import com.android.settingslib.core.AbstractPreferenceController; +import com.android.settingslib.core.instrumentation.MetricsFeatureProvider; +import com.android.settingslib.core.lifecycle.Lifecycle; +import com.android.settingslib.core.lifecycle.LifecycleObserver; +import com.android.settingslib.core.lifecycle.events.OnCreate; +import com.android.settingslib.core.lifecycle.events.OnDestroy; +import com.android.settingslib.core.lifecycle.events.OnResume; +import com.android.settingslib.core.lifecycle.events.OnSaveInstanceState; +import com.android.settingslib.utils.StringUtil; +import com.android.settingslib.widget.FooterPreference; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** Controls the update for chart graph and the list items. */ +public class BatteryChartPreferenceControllerV2 extends AbstractPreferenceController + implements PreferenceControllerMixin, LifecycleObserver, OnCreate, OnDestroy, + OnSaveInstanceState, BatteryChartViewV2.OnSelectListener, OnResume, + ExpandDividerPreference.OnExpandListener { + private static final String TAG = "BatteryChartPreferenceControllerV2"; + private static final String KEY_FOOTER_PREF = "battery_graph_footer"; + private static final String PACKAGE_NAME_NONE = "none"; + + /** Desired battery history size for timestamp slots. */ + public static final int DESIRED_HISTORY_SIZE = 25; + private static final int CHART_LEVEL_ARRAY_SIZE = 13; + private static final int CHART_KEY_ARRAY_SIZE = DESIRED_HISTORY_SIZE; + private static final long VALID_USAGE_TIME_DURATION = DateUtils.HOUR_IN_MILLIS * 2; + private static final long VALID_DIFF_DURATION = DateUtils.MINUTE_IN_MILLIS * 3; + + // Keys for bundle instance to restore configurations. + private static final String KEY_EXPAND_SYSTEM_INFO = "expand_system_info"; + private static final String KEY_CURRENT_TIME_SLOT = "current_time_slot"; + + private static int sUiMode = Configuration.UI_MODE_NIGHT_UNDEFINED; + + @VisibleForTesting + Map> mBatteryIndexedMap; + + @VisibleForTesting + Context mPrefContext; + @VisibleForTesting + BatteryUtils mBatteryUtils; + @VisibleForTesting + PreferenceGroup mAppListPrefGroup; + @VisibleForTesting + BatteryChartViewV2 mBatteryChartView; + @VisibleForTesting + ExpandDividerPreference mExpandDividerPreference; + + @VisibleForTesting + boolean mIsExpanded = false; + @VisibleForTesting + int[] mBatteryHistoryLevels; + @VisibleForTesting + long[] mBatteryHistoryKeys; + @VisibleForTesting + int mTrapezoidIndex = BatteryChartViewV2.SELECTED_INDEX_INVALID; + + private boolean mIs24HourFormat = false; + private boolean mIsFooterPrefAdded = false; + private PreferenceScreen mPreferenceScreen; + private FooterPreference mFooterPreference; + + private final String mPreferenceKey; + private final SettingsActivity mActivity; + private final InstrumentedPreferenceFragment mFragment; + private final CharSequence[] mNotAllowShowEntryPackages; + private final CharSequence[] mNotAllowShowSummaryPackages; + private final MetricsFeatureProvider mMetricsFeatureProvider; + private final Handler mHandler = new Handler(Looper.getMainLooper()); + + // Preference cache to avoid create new instance each time. + @VisibleForTesting + final Map mPreferenceCache = new HashMap<>(); + @VisibleForTesting + final List mSystemEntries = new ArrayList<>(); + + public BatteryChartPreferenceControllerV2( + Context context, String preferenceKey, + Lifecycle lifecycle, SettingsActivity activity, + InstrumentedPreferenceFragment fragment) { + super(context); + mActivity = activity; + mFragment = fragment; + mPreferenceKey = preferenceKey; + mIs24HourFormat = DateFormat.is24HourFormat(context); + mMetricsFeatureProvider = + FeatureFactory.getFactory(mContext).getMetricsFeatureProvider(); + mNotAllowShowEntryPackages = + FeatureFactory.getFactory(context) + .getPowerUsageFeatureProvider(context) + .getHideApplicationEntries(context); + mNotAllowShowSummaryPackages = + FeatureFactory.getFactory(context) + .getPowerUsageFeatureProvider(context) + .getHideApplicationSummary(context); + if (lifecycle != null) { + lifecycle.addObserver(this); + } + } + + @Override + public void onCreate(Bundle savedInstanceState) { + if (savedInstanceState == null) { + return; + } + mTrapezoidIndex = + savedInstanceState.getInt(KEY_CURRENT_TIME_SLOT, mTrapezoidIndex); + mIsExpanded = + savedInstanceState.getBoolean(KEY_EXPAND_SYSTEM_INFO, mIsExpanded); + Log.d(TAG, String.format("onCreate() slotIndex=%d isExpanded=%b", + mTrapezoidIndex, mIsExpanded)); + } + + @Override + public void onResume() { + final int currentUiMode = + mContext.getResources().getConfiguration().uiMode + & Configuration.UI_MODE_NIGHT_MASK; + if (sUiMode != currentUiMode) { + sUiMode = currentUiMode; + BatteryDiffEntry.clearCache(); + Log.d(TAG, "clear icon and label cache since uiMode is changed"); + } + mIs24HourFormat = DateFormat.is24HourFormat(mContext); + mMetricsFeatureProvider.action(mPrefContext, SettingsEnums.OPEN_BATTERY_USAGE); + } + + @Override + public void onSaveInstanceState(Bundle savedInstance) { + if (savedInstance == null) { + return; + } + savedInstance.putInt(KEY_CURRENT_TIME_SLOT, mTrapezoidIndex); + savedInstance.putBoolean(KEY_EXPAND_SYSTEM_INFO, mIsExpanded); + Log.d(TAG, String.format("onSaveInstanceState() slotIndex=%d isExpanded=%b", + mTrapezoidIndex, mIsExpanded)); + } + + @Override + public void onDestroy() { + if (mActivity.isChangingConfigurations()) { + BatteryDiffEntry.clearCache(); + } + mHandler.removeCallbacksAndMessages(/*token=*/ null); + mPreferenceCache.clear(); + if (mAppListPrefGroup != null) { + mAppListPrefGroup.removeAll(); + } + } + + @Override + public void displayPreference(PreferenceScreen screen) { + super.displayPreference(screen); + mPreferenceScreen = screen; + mPrefContext = screen.getContext(); + mAppListPrefGroup = screen.findPreference(mPreferenceKey); + mAppListPrefGroup.setOrderingAsAdded(false); + mAppListPrefGroup.setTitle( + mPrefContext.getString(R.string.battery_app_usage_for_past_24)); + mFooterPreference = screen.findPreference(KEY_FOOTER_PREF); + // Removes footer first until usage data is loaded to avoid flashing. + if (mFooterPreference != null) { + screen.removePreference(mFooterPreference); + } + } + + @Override + public boolean isAvailable() { + return true; + } + + @Override + public String getPreferenceKey() { + return mPreferenceKey; + } + + @Override + public boolean handlePreferenceTreeClick(Preference preference) { + if (!(preference instanceof PowerGaugePreference)) { + return false; + } + final PowerGaugePreference powerPref = (PowerGaugePreference) preference; + final BatteryDiffEntry diffEntry = powerPref.getBatteryDiffEntry(); + final BatteryHistEntry histEntry = diffEntry.mBatteryHistEntry; + final String packageName = histEntry.mPackageName; + final boolean isAppEntry = histEntry.isAppEntry(); + mMetricsFeatureProvider.action( + /* attribution */ SettingsEnums.OPEN_BATTERY_USAGE, + /* action */ isAppEntry + ? SettingsEnums.ACTION_BATTERY_USAGE_APP_ITEM + : SettingsEnums.ACTION_BATTERY_USAGE_SYSTEM_ITEM, + /* pageId */ SettingsEnums.OPEN_BATTERY_USAGE, + TextUtils.isEmpty(packageName) ? PACKAGE_NAME_NONE : packageName, + (int) Math.round(diffEntry.getPercentOfTotal())); + Log.d(TAG, String.format("handleClick() label=%s key=%s package=%s", + diffEntry.getAppLabel(), histEntry.getKey(), histEntry.mPackageName)); + AdvancedPowerUsageDetail.startBatteryDetailPage( + mActivity, mFragment, diffEntry, powerPref.getPercent(), + isValidToShowSummary(packageName), getSlotInformation()); + return true; + } + + @Override + public void onSelect(int trapezoidIndex) { + Log.d(TAG, "onChartSelect:" + trapezoidIndex); + refreshUi(trapezoidIndex, /*isForce=*/ false); + mMetricsFeatureProvider.action( + mPrefContext, + trapezoidIndex == BatteryChartViewV2.SELECTED_INDEX_ALL + ? SettingsEnums.ACTION_BATTERY_USAGE_SHOW_ALL + : SettingsEnums.ACTION_BATTERY_USAGE_TIME_SLOT); + } + + @Override + public void onExpand(boolean isExpanded) { + mIsExpanded = isExpanded; + mMetricsFeatureProvider.action( + mPrefContext, + SettingsEnums.ACTION_BATTERY_USAGE_EXPAND_ITEM, + isExpanded); + refreshExpandUi(); + } + + void setBatteryHistoryMap( + final Map> batteryHistoryMap) { + // Resets all battery history data relative variables. + if (batteryHistoryMap == null || batteryHistoryMap.isEmpty()) { + mBatteryIndexedMap = null; + mBatteryHistoryKeys = null; + mBatteryHistoryLevels = null; + addFooterPreferenceIfNeeded(false); + return; + } + mBatteryHistoryKeys = getBatteryHistoryKeys(batteryHistoryMap); + mBatteryHistoryLevels = new int[CHART_LEVEL_ARRAY_SIZE]; + for (int index = 0; index < CHART_LEVEL_ARRAY_SIZE; index++) { + final long timestamp = mBatteryHistoryKeys[index * 2]; + final Map entryMap = batteryHistoryMap.get(timestamp); + if (entryMap == null || entryMap.isEmpty()) { + Log.e(TAG, "abnormal entry list in the timestamp:" + + ConvertUtils.utcToLocalTime(mPrefContext, timestamp)); + continue; + } + // Averages the battery level in each time slot to avoid corner conditions. + float batteryLevelCounter = 0; + for (BatteryHistEntry entry : entryMap.values()) { + batteryLevelCounter += entry.mBatteryLevel; + } + mBatteryHistoryLevels[index] = + Math.round(batteryLevelCounter / entryMap.size()); + } + forceRefreshUi(); + Log.d(TAG, String.format( + "setBatteryHistoryMap() size=%d key=%s\nlevels=%s", + batteryHistoryMap.size(), + ConvertUtils.utcToLocalTime(mPrefContext, + mBatteryHistoryKeys[mBatteryHistoryKeys.length - 1]), + Arrays.toString(mBatteryHistoryLevels))); + + // Loads item icon and label in the background. + new LoadAllItemsInfoTask(batteryHistoryMap).execute(); + } + + void setBatteryChartView(final BatteryChartViewV2 batteryChartView) { + if (mBatteryChartView != batteryChartView) { + mHandler.post(() -> setBatteryChartViewInner(batteryChartView)); + } + } + + private void setBatteryChartViewInner(final BatteryChartViewV2 batteryChartView) { + mBatteryChartView = batteryChartView; + mBatteryChartView.setOnSelectListener(this); + forceRefreshUi(); + } + + private void forceRefreshUi() { + final int refreshIndex = + mTrapezoidIndex == BatteryChartViewV2.SELECTED_INDEX_INVALID + ? BatteryChartViewV2.SELECTED_INDEX_ALL + : mTrapezoidIndex; + if (mBatteryChartView != null) { + mBatteryChartView.setLevels(mBatteryHistoryLevels); + mBatteryChartView.setSelectedIndex(refreshIndex); + setTimestampLabel(); + } + refreshUi(refreshIndex, /*isForce=*/ true); + } + + @VisibleForTesting + boolean refreshUi(int trapezoidIndex, boolean isForce) { + // Invalid refresh condition. + if (mBatteryIndexedMap == null + || mBatteryChartView == null + || (mTrapezoidIndex == trapezoidIndex && !isForce)) { + return false; + } + Log.d(TAG, String.format("refreshUi: index=%d size=%d isForce:%b", + trapezoidIndex, mBatteryIndexedMap.size(), isForce)); + + mTrapezoidIndex = trapezoidIndex; + mHandler.post(() -> { + final long start = System.currentTimeMillis(); + removeAndCacheAllPrefs(); + addAllPreferences(); + refreshCategoryTitle(); + Log.d(TAG, String.format("refreshUi is finished in %d/ms", + (System.currentTimeMillis() - start))); + }); + return true; + } + + private void addAllPreferences() { + final List entries = + mBatteryIndexedMap.get(Integer.valueOf(mTrapezoidIndex)); + addFooterPreferenceIfNeeded(entries != null && !entries.isEmpty()); + if (entries == null) { + Log.w(TAG, "cannot find BatteryDiffEntry for:" + mTrapezoidIndex); + return; + } + // Separates data into two groups and sort them individually. + final List appEntries = new ArrayList<>(); + mSystemEntries.clear(); + entries.forEach(entry -> { + final String packageName = entry.getPackageName(); + if (!isValidToShowEntry(packageName)) { + Log.w(TAG, "ignore showing item:" + packageName); + return; + } + if (entry.isSystemEntry()) { + mSystemEntries.add(entry); + } else { + appEntries.add(entry); + } + // Validates the usage time if users click a specific slot. + if (mTrapezoidIndex >= 0) { + validateUsageTime(entry); + } + }); + Collections.sort(appEntries, BatteryDiffEntry.COMPARATOR); + Collections.sort(mSystemEntries, BatteryDiffEntry.COMPARATOR); + Log.d(TAG, String.format("addAllPreferences() app=%d system=%d", + appEntries.size(), mSystemEntries.size())); + + // Adds app entries to the list if it is not empty. + if (!appEntries.isEmpty()) { + addPreferenceToScreen(appEntries); + } + // Adds the expabable divider if we have system entries data. + if (!mSystemEntries.isEmpty()) { + if (mExpandDividerPreference == null) { + mExpandDividerPreference = new ExpandDividerPreference(mPrefContext); + mExpandDividerPreference.setOnExpandListener(this); + mExpandDividerPreference.setIsExpanded(mIsExpanded); + } + mExpandDividerPreference.setOrder( + mAppListPrefGroup.getPreferenceCount()); + mAppListPrefGroup.addPreference(mExpandDividerPreference); + } + refreshExpandUi(); + } + + @VisibleForTesting + void addPreferenceToScreen(List entries) { + if (mAppListPrefGroup == null || entries.isEmpty()) { + return; + } + int prefIndex = mAppListPrefGroup.getPreferenceCount(); + for (BatteryDiffEntry entry : entries) { + boolean isAdded = false; + final String appLabel = entry.getAppLabel(); + final Drawable appIcon = entry.getAppIcon(); + if (TextUtils.isEmpty(appLabel) || appIcon == null) { + Log.w(TAG, "cannot find app resource for:" + entry.getPackageName()); + continue; + } + final String prefKey = entry.mBatteryHistEntry.getKey(); + PowerGaugePreference pref = mAppListPrefGroup.findPreference(prefKey); + if (pref != null) { + isAdded = true; + Log.w(TAG, "preference should be removed for:" + entry.getPackageName()); + } else { + pref = (PowerGaugePreference) mPreferenceCache.get(prefKey); + } + // Creates new innstance if cached preference is not found. + if (pref == null) { + pref = new PowerGaugePreference(mPrefContext); + pref.setKey(prefKey); + mPreferenceCache.put(prefKey, pref); + } + pref.setIcon(appIcon); + pref.setTitle(appLabel); + pref.setOrder(prefIndex); + pref.setPercent(entry.getPercentOfTotal()); + pref.setSingleLineTitle(true); + // Sets the BatteryDiffEntry to preference for launching detailed page. + pref.setBatteryDiffEntry(entry); + pref.setEnabled(entry.validForRestriction()); + setPreferenceSummary(pref, entry); + if (!isAdded) { + mAppListPrefGroup.addPreference(pref); + } + prefIndex++; + } + } + + private void removeAndCacheAllPrefs() { + if (mAppListPrefGroup == null + || mAppListPrefGroup.getPreferenceCount() == 0) { + return; + } + final int prefsCount = mAppListPrefGroup.getPreferenceCount(); + for (int index = 0; index < prefsCount; index++) { + final Preference pref = mAppListPrefGroup.getPreference(index); + if (TextUtils.isEmpty(pref.getKey())) { + continue; + } + mPreferenceCache.put(pref.getKey(), pref); + } + mAppListPrefGroup.removeAll(); + } + + private void refreshExpandUi() { + if (mIsExpanded) { + addPreferenceToScreen(mSystemEntries); + } else { + // Removes and recycles all system entries to hide all of them. + for (BatteryDiffEntry entry : mSystemEntries) { + final String prefKey = entry.mBatteryHistEntry.getKey(); + final Preference pref = mAppListPrefGroup.findPreference(prefKey); + if (pref != null) { + mAppListPrefGroup.removePreference(pref); + mPreferenceCache.put(pref.getKey(), pref); + } + } + } + } + + @VisibleForTesting + void refreshCategoryTitle() { + final String slotInformation = getSlotInformation(); + Log.d(TAG, String.format("refreshCategoryTitle:%s", slotInformation)); + if (mAppListPrefGroup != null) { + mAppListPrefGroup.setTitle( + getSlotInformation(/*isApp=*/ true, slotInformation)); + } + if (mExpandDividerPreference != null) { + mExpandDividerPreference.setTitle( + getSlotInformation(/*isApp=*/ false, slotInformation)); + } + } + + private String getSlotInformation(boolean isApp, String slotInformation) { + // Null means we show all information without a specific time slot. + if (slotInformation == null) { + return isApp + ? mPrefContext.getString(R.string.battery_app_usage_for_past_24) + : mPrefContext.getString(R.string.battery_system_usage_for_past_24); + } else { + return isApp + ? mPrefContext.getString(R.string.battery_app_usage_for, slotInformation) + : mPrefContext.getString(R.string.battery_system_usage_for, slotInformation); + } + } + + private String getSlotInformation() { + if (mTrapezoidIndex < 0) { + return null; + } + final String fromHour = ConvertUtils.utcToLocalTimeHour(mPrefContext, + mBatteryHistoryKeys[mTrapezoidIndex * 2], mIs24HourFormat); + final String toHour = ConvertUtils.utcToLocalTimeHour(mPrefContext, + mBatteryHistoryKeys[(mTrapezoidIndex + 1) * 2], mIs24HourFormat); + return mIs24HourFormat + ? String.format("%s–%s", fromHour, toHour) + : String.format("%s – %s", fromHour, toHour); + } + + @VisibleForTesting + void setPreferenceSummary( + PowerGaugePreference preference, BatteryDiffEntry entry) { + final long foregroundUsageTimeInMs = entry.mForegroundUsageTimeInMs; + final long backgroundUsageTimeInMs = entry.mBackgroundUsageTimeInMs; + final long totalUsageTimeInMs = foregroundUsageTimeInMs + backgroundUsageTimeInMs; + // Checks whether the package is allowed to show summary or not. + if (!isValidToShowSummary(entry.getPackageName())) { + preference.setSummary(null); + return; + } + String usageTimeSummary = null; + // Not shows summary for some system components without usage time. + if (totalUsageTimeInMs == 0) { + preference.setSummary(null); + // Shows background summary only if we don't have foreground usage time. + } else if (foregroundUsageTimeInMs == 0 && backgroundUsageTimeInMs != 0) { + usageTimeSummary = buildUsageTimeInfo(backgroundUsageTimeInMs, true); + // Shows total usage summary only if total usage time is small. + } else if (totalUsageTimeInMs < DateUtils.MINUTE_IN_MILLIS) { + usageTimeSummary = buildUsageTimeInfo(totalUsageTimeInMs, false); + } else { + usageTimeSummary = buildUsageTimeInfo(totalUsageTimeInMs, false); + // Shows background usage time if it is larger than a minute. + if (backgroundUsageTimeInMs > 0) { + usageTimeSummary += + "\n" + buildUsageTimeInfo(backgroundUsageTimeInMs, true); + } + } + preference.setSummary(usageTimeSummary); + } + + private String buildUsageTimeInfo(long usageTimeInMs, boolean isBackground) { + if (usageTimeInMs < DateUtils.MINUTE_IN_MILLIS) { + return mPrefContext.getString( + isBackground + ? R.string.battery_usage_background_less_than_one_minute + : R.string.battery_usage_total_less_than_one_minute); + } + final CharSequence timeSequence = + StringUtil.formatElapsedTime(mPrefContext, usageTimeInMs, + /*withSeconds=*/ false, /*collapseTimeUnit=*/ false); + final int resourceId = + isBackground + ? R.string.battery_usage_for_background_time + : R.string.battery_usage_for_total_time; + return mPrefContext.getString(resourceId, timeSequence); + } + + @VisibleForTesting + boolean isValidToShowSummary(String packageName) { + return !contains(packageName, mNotAllowShowSummaryPackages); + } + + @VisibleForTesting + boolean isValidToShowEntry(String packageName) { + return !contains(packageName, mNotAllowShowEntryPackages); + } + + @VisibleForTesting + void setTimestampLabel() { + if (mBatteryChartView == null || mBatteryHistoryKeys == null) { + return; + } + final long latestTimestamp = + mBatteryHistoryKeys[mBatteryHistoryKeys.length - 1]; + mBatteryChartView.setLatestTimestamp(latestTimestamp); + } + + private void addFooterPreferenceIfNeeded(boolean containAppItems) { + if (mIsFooterPrefAdded || mFooterPreference == null) { + return; + } + mIsFooterPrefAdded = true; + mFooterPreference.setTitle(mPrefContext.getString( + containAppItems + ? R.string.battery_usage_screen_footer + : R.string.battery_usage_screen_footer_empty)); + mHandler.post(() -> mPreferenceScreen.addPreference(mFooterPreference)); + } + + private static boolean contains(String target, CharSequence[] packageNames) { + if (target != null && packageNames != null) { + for (CharSequence packageName : packageNames) { + if (TextUtils.equals(target, packageName)) { + return true; + } + } + } + return false; + } + + @VisibleForTesting + static boolean validateUsageTime(BatteryDiffEntry entry) { + final long foregroundUsageTimeInMs = entry.mForegroundUsageTimeInMs; + final long backgroundUsageTimeInMs = entry.mBackgroundUsageTimeInMs; + final long totalUsageTimeInMs = foregroundUsageTimeInMs + backgroundUsageTimeInMs; + if (foregroundUsageTimeInMs > VALID_USAGE_TIME_DURATION + || backgroundUsageTimeInMs > VALID_USAGE_TIME_DURATION + || totalUsageTimeInMs > VALID_USAGE_TIME_DURATION) { + Log.e(TAG, "validateUsageTime() fail for\n" + entry); + return false; + } + return true; + } + + /** Used for {@link AppBatteryPreferenceController}. */ + public static List getBatteryLast24HrUsageData(Context context) { + final long start = System.currentTimeMillis(); + final Map> batteryHistoryMap = + FeatureFactory.getFactory(context) + .getPowerUsageFeatureProvider(context) + .getBatteryHistory(context); + if (batteryHistoryMap == null || batteryHistoryMap.isEmpty()) { + return null; + } + Log.d(TAG, String.format("getBatteryLast24HrData() size=%d time=%d/ms", + batteryHistoryMap.size(), (System.currentTimeMillis() - start))); + final Map> batteryIndexedMap = + ConvertUtils.getIndexedUsageMap( + context, + /*timeSlotSize=*/ CHART_LEVEL_ARRAY_SIZE - 1, + getBatteryHistoryKeys(batteryHistoryMap), + batteryHistoryMap, + /*purgeLowPercentageAndFakeData=*/ true); + return batteryIndexedMap.get(BatteryChartViewV2.SELECTED_INDEX_ALL); + } + + /** Used for {@link AppBatteryPreferenceController}. */ + public static BatteryDiffEntry getBatteryLast24HrUsageData( + Context context, String packageName, int userId) { + if (packageName == null) { + return null; + } + final List entries = getBatteryLast24HrUsageData(context); + if (entries == null) { + return null; + } + for (BatteryDiffEntry entry : entries) { + final BatteryHistEntry batteryHistEntry = entry.mBatteryHistEntry; + if (batteryHistEntry != null + && batteryHistEntry.mConsumerType == ConvertUtils.CONSUMER_TYPE_UID_BATTERY + && batteryHistEntry.mUserId == userId + && packageName.equals(entry.getPackageName())) { + return entry; + } + } + return null; + } + + private static long[] getBatteryHistoryKeys( + final Map> batteryHistoryMap) { + final List batteryHistoryKeyList = + new ArrayList<>(batteryHistoryMap.keySet()); + Collections.sort(batteryHistoryKeyList); + final long[] batteryHistoryKeys = new long[CHART_KEY_ARRAY_SIZE]; + for (int index = 0; index < CHART_KEY_ARRAY_SIZE; index++) { + batteryHistoryKeys[index] = batteryHistoryKeyList.get(index); + } + return batteryHistoryKeys; + } + + // Loads all items icon and label in the background. + private final class LoadAllItemsInfoTask + extends AsyncTask>> { + + private long[] mBatteryHistoryKeysCache; + private Map> mBatteryHistoryMap; + + private LoadAllItemsInfoTask( + Map> batteryHistoryMap) { + this.mBatteryHistoryMap = batteryHistoryMap; + this.mBatteryHistoryKeysCache = mBatteryHistoryKeys; + } + + @Override + protected Map> doInBackground(Void... voids) { + if (mPrefContext == null || mBatteryHistoryKeysCache == null) { + return null; + } + final long startTime = System.currentTimeMillis(); + final Map> indexedUsageMap = + ConvertUtils.getIndexedUsageMap( + mPrefContext, /*timeSlotSize=*/ CHART_LEVEL_ARRAY_SIZE - 1, + mBatteryHistoryKeysCache, mBatteryHistoryMap, + /*purgeLowPercentageAndFakeData=*/ true); + // Pre-loads each BatteryDiffEntry relative icon and label for all slots. + for (List entries : indexedUsageMap.values()) { + entries.forEach(entry -> entry.loadLabelAndIcon()); + } + Log.d(TAG, String.format("execute LoadAllItemsInfoTask in %d/ms", + (System.currentTimeMillis() - startTime))); + return indexedUsageMap; + } + + @Override + protected void onPostExecute( + Map> indexedUsageMap) { + mBatteryHistoryMap = null; + mBatteryHistoryKeysCache = null; + if (indexedUsageMap == null) { + return; + } + // Posts results back to main thread to refresh UI. + mHandler.post(() -> { + mBatteryIndexedMap = indexedUsageMap; + forceRefreshUi(); + }); + } + } +} diff --git a/src/com/android/settings/fuelgauge/batteryusage/BatteryChartViewV2.java b/src/com/android/settings/fuelgauge/batteryusage/BatteryChartViewV2.java new file mode 100644 index 00000000000..7c55c402897 --- /dev/null +++ b/src/com/android/settings/fuelgauge/batteryusage/BatteryChartViewV2.java @@ -0,0 +1,633 @@ +/* + * 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.Utils.formatPercentage; + +import static java.lang.Math.round; + +import android.accessibilityservice.AccessibilityServiceInfo; +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.CornerPathEffect; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.Rect; +import android.os.Handler; +import android.text.format.DateFormat; +import android.text.format.DateUtils; +import android.util.AttributeSet; +import android.util.Log; +import android.view.HapticFeedbackConstants; +import android.view.MotionEvent; +import android.view.View; +import android.view.accessibility.AccessibilityManager; +import android.widget.TextView; + +import androidx.annotation.VisibleForTesting; +import androidx.appcompat.widget.AppCompatImageView; + +import com.android.settings.R; +import com.android.settings.overlay.FeatureFactory; +import com.android.settingslib.Utils; + +import java.time.Clock; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; + +/** A widget component to draw chart graph. */ +public class BatteryChartViewV2 extends AppCompatImageView implements View.OnClickListener, + AccessibilityManager.AccessibilityStateChangeListener { + private static final String TAG = "BatteryChartViewV2"; + private static final List ACCESSIBILITY_SERVICE_NAMES = + Arrays.asList("SwitchAccessService", "TalkBackService", "JustSpeakService"); + + private static final int DEFAULT_TRAPEZOID_COUNT = 12; + private static final int DEFAULT_TIMESTAMP_COUNT = 4; + private static final int TIMESTAMP_GAPS_COUNT = DEFAULT_TIMESTAMP_COUNT - 1; + private static final int DIVIDER_COLOR = Color.parseColor("#CDCCC5"); + private static final long UPDATE_STATE_DELAYED_TIME = 500L; + + /** Selects all trapezoid shapes. */ + public static final int SELECTED_INDEX_ALL = -1; + public static final int SELECTED_INDEX_INVALID = -2; + + /** A callback listener for selected group index is updated. */ + public interface OnSelectListener { + /** The callback function for selected group index is updated. */ + void onSelect(int trapezoidIndex); + } + + private int mDividerWidth; + private int mDividerHeight; + private int mTrapezoidCount; + private float mTrapezoidVOffset; + private float mTrapezoidHOffset; + private boolean mIsSlotsClickabled; + private String[] mPercentages = getPercentages(); + + @VisibleForTesting + int mHoveredIndex = SELECTED_INDEX_INVALID; + @VisibleForTesting + int mSelectedIndex = SELECTED_INDEX_INVALID; + @VisibleForTesting + String[] mTimestamps; + + // Colors for drawing the trapezoid shape and dividers. + private int mTrapezoidColor; + private int mTrapezoidSolidColor; + private int mTrapezoidHoverColor; + // For drawing the percentage information. + private int mTextPadding; + private final Rect mIndent = new Rect(); + private final Rect[] mPercentageBounds = + new Rect[]{new Rect(), new Rect(), new Rect()}; + // For drawing the timestamp information. + private final Rect[] mTimestampsBounds = + new Rect[]{new Rect(), new Rect(), new Rect(), new Rect()}; + + @VisibleForTesting + Handler mHandler = new Handler(); + @VisibleForTesting + final Runnable mUpdateClickableStateRun = () -> updateClickableState(); + + private int[] mLevels; + private Paint mTextPaint; + private Paint mDividerPaint; + private Paint mTrapezoidPaint; + + @VisibleForTesting + Paint mTrapezoidCurvePaint = null; + private TrapezoidSlot[] mTrapezoidSlots; + // Records the location to calculate selected index. + private float mTouchUpEventX = Float.MIN_VALUE; + private BatteryChartViewV2.OnSelectListener mOnSelectListener; + + public BatteryChartViewV2(Context context) { + super(context, null); + } + + public BatteryChartViewV2(Context context, AttributeSet attrs) { + super(context, attrs); + initializeColors(context); + // Registers the click event listener. + setOnClickListener(this); + setSelectedIndex(SELECTED_INDEX_ALL); + setTrapezoidCount(DEFAULT_TRAPEZOID_COUNT); + setClickable(false); + setLatestTimestamp(0); + } + + /** Sets the total trapezoid count for drawing. */ + public void setTrapezoidCount(int trapezoidCount) { + Log.i(TAG, "trapezoidCount:" + trapezoidCount); + mTrapezoidCount = trapezoidCount; + mTrapezoidSlots = new TrapezoidSlot[trapezoidCount]; + // Allocates the trapezoid slot array. + for (int index = 0; index < trapezoidCount; index++) { + mTrapezoidSlots[index] = new TrapezoidSlot(); + } + invalidate(); + } + + /** Sets all levels value to draw the trapezoid shape */ + public void setLevels(int[] levels) { + Log.d(TAG, "setLevels() " + (levels == null ? "null" : levels.length)); + if (levels == null) { + mLevels = null; + return; + } + // We should provide trapezoid count + 1 data to draw all trapezoids. + mLevels = levels.length == mTrapezoidCount + 1 ? levels : null; + setClickable(false); + invalidate(); + if (mLevels == null) { + return; + } + // Sets the chart is clickable if there is at least one valid item in it. + for (int index = 0; index < mLevels.length - 1; index++) { + if (mLevels[index] != 0 && mLevels[index + 1] != 0) { + setClickable(true); + break; + } + } + } + + /** Sets the selected group index to draw highlight effect. */ + public void setSelectedIndex(int index) { + if (mSelectedIndex != index) { + mSelectedIndex = index; + invalidate(); + // Callbacks to the listener if we have. + if (mOnSelectListener != null) { + mOnSelectListener.onSelect(mSelectedIndex); + } + } + } + + /** Sets the callback to monitor the selected group index. */ + public void setOnSelectListener(BatteryChartViewV2.OnSelectListener listener) { + mOnSelectListener = listener; + } + + /** Sets the companion {@link TextView} for percentage information. */ + public void setCompanionTextView(TextView textView) { + if (textView != null) { + // Pre-draws the view first to load style atttributions into paint. + textView.draw(new Canvas()); + mTextPaint = textView.getPaint(); + } else { + mTextPaint = null; + } + setVisibility(View.VISIBLE); + requestLayout(); + } + + /** Sets the latest timestamp for drawing into x-axis information. */ + public void setLatestTimestamp(long latestTimestamp) { + if (latestTimestamp == 0) { + latestTimestamp = Clock.systemUTC().millis(); + } + if (mTimestamps == null) { + mTimestamps = new String[DEFAULT_TIMESTAMP_COUNT]; + } + final long timeSlotOffset = + DateUtils.HOUR_IN_MILLIS * (/*total 24 hours*/ 24 / TIMESTAMP_GAPS_COUNT); + final boolean is24HourFormat = DateFormat.is24HourFormat(getContext()); + for (int index = 0; index < DEFAULT_TIMESTAMP_COUNT; index++) { + mTimestamps[index] = + ConvertUtils.utcToLocalTimeHour( + getContext(), + latestTimestamp - (TIMESTAMP_GAPS_COUNT - index) + * timeSlotOffset, + is24HourFormat); + } + requestLayout(); + } + + @Override + public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + // Measures text bounds and updates indent configuration. + if (mTextPaint != null) { + for (int index = 0; index < mPercentages.length; index++) { + mTextPaint.getTextBounds( + mPercentages[index], 0, mPercentages[index].length(), + mPercentageBounds[index]); + } + // Updates the indent configurations. + mIndent.top = mPercentageBounds[0].height(); + mIndent.right = mPercentageBounds[0].width() + mTextPadding; + + if (mTimestamps != null) { + int maxHeight = 0; + for (int index = 0; index < DEFAULT_TIMESTAMP_COUNT; index++) { + mTextPaint.getTextBounds( + mTimestamps[index], 0, mTimestamps[index].length(), + mTimestampsBounds[index]); + maxHeight = Math.max(maxHeight, mTimestampsBounds[index].height()); + } + mIndent.bottom = maxHeight + round(mTextPadding * 1.5f); + } + Log.d(TAG, "setIndent:" + mPercentageBounds[0]); + } else { + mIndent.set(0, 0, 0, 0); + } + } + + @Override + public void draw(Canvas canvas) { + super.draw(canvas); + drawHorizontalDividers(canvas); + drawVerticalDividers(canvas); + drawTrapezoids(canvas); + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + // Caches the location to calculate selected trapezoid index. + final int action = event.getAction(); + switch (action) { + case MotionEvent.ACTION_UP: + mTouchUpEventX = event.getX(); + break; + case MotionEvent.ACTION_CANCEL: + mTouchUpEventX = Float.MIN_VALUE; // reset + break; + } + return super.onTouchEvent(event); + } + + @Override + public boolean onHoverEvent(MotionEvent event) { + final int action = event.getAction(); + switch (action) { + case MotionEvent.ACTION_HOVER_ENTER: + case MotionEvent.ACTION_HOVER_MOVE: + final int trapezoidIndex = getTrapezoidIndex(event.getX()); + if (mHoveredIndex != trapezoidIndex) { + mHoveredIndex = trapezoidIndex; + invalidate(); + } + break; + } + return super.onHoverEvent(event); + } + + @Override + public void onHoverChanged(boolean hovered) { + super.onHoverChanged(hovered); + if (!hovered) { + mHoveredIndex = SELECTED_INDEX_INVALID; // reset + invalidate(); + } + } + + @Override + public void onClick(View view) { + if (mTouchUpEventX == Float.MIN_VALUE) { + Log.w(TAG, "invalid motion event for onClick() callback"); + return; + } + final int trapezoidIndex = getTrapezoidIndex(mTouchUpEventX); + // Ignores the click event if the level is zero. + if (trapezoidIndex == SELECTED_INDEX_INVALID + || !isValidToDraw(trapezoidIndex)) { + return; + } + // Selects all if users click the same trapezoid item two times. + if (trapezoidIndex == mSelectedIndex) { + setSelectedIndex(SELECTED_INDEX_ALL); + } else { + setSelectedIndex(trapezoidIndex); + } + view.performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK); + } + + @Override + public void onAttachedToWindow() { + super.onAttachedToWindow(); + updateClickableState(); + mContext.getSystemService(AccessibilityManager.class) + .addAccessibilityStateChangeListener(/*listener=*/ this); + } + + @Override + public void onDetachedFromWindow() { + super.onDetachedFromWindow(); + mContext.getSystemService(AccessibilityManager.class) + .removeAccessibilityStateChangeListener(/*listener=*/ this); + mHandler.removeCallbacks(mUpdateClickableStateRun); + } + + @Override + public void onAccessibilityStateChanged(boolean enabled) { + Log.d(TAG, "onAccessibilityStateChanged:" + enabled); + mHandler.removeCallbacks(mUpdateClickableStateRun); + // We should delay it a while since accessibility manager will spend + // some times to bind with new enabled accessibility services. + mHandler.postDelayed( + mUpdateClickableStateRun, UPDATE_STATE_DELAYED_TIME); + } + + private void updateClickableState() { + final Context context = mContext; + mIsSlotsClickabled = + FeatureFactory.getFactory(context) + .getPowerUsageFeatureProvider(context) + .isChartGraphSlotsEnabled(context) + && !isAccessibilityEnabled(context); + Log.d(TAG, "isChartGraphSlotsEnabled:" + mIsSlotsClickabled); + setClickable(isClickable()); + // Initializes the trapezoid curve paint for non-clickable case. + if (!mIsSlotsClickabled && mTrapezoidCurvePaint == null) { + mTrapezoidCurvePaint = new Paint(); + mTrapezoidCurvePaint.setAntiAlias(true); + mTrapezoidCurvePaint.setColor(mTrapezoidSolidColor); + mTrapezoidCurvePaint.setStyle(Paint.Style.STROKE); + mTrapezoidCurvePaint.setStrokeWidth(mDividerWidth * 2); + } else if (mIsSlotsClickabled) { + mTrapezoidCurvePaint = null; + // Sets levels again to force update the click state. + setLevels(mLevels); + } + invalidate(); + } + + @Override + public void setClickable(boolean clickable) { + super.setClickable(mIsSlotsClickabled && clickable); + } + + @VisibleForTesting + void setClickableForce(boolean clickable) { + super.setClickable(clickable); + } + + private void initializeColors(Context context) { + setBackgroundColor(Color.TRANSPARENT); + mTrapezoidSolidColor = Utils.getColorAccentDefaultColor(context); + mTrapezoidColor = Utils.getDisabled(context, mTrapezoidSolidColor); + mTrapezoidHoverColor = Utils.getColorAttrDefaultColor(context, + com.android.internal.R.attr.colorAccentSecondaryVariant); + // Initializes the divider line paint. + final Resources resources = getContext().getResources(); + mDividerWidth = resources.getDimensionPixelSize(R.dimen.chartview_divider_width); + mDividerHeight = resources.getDimensionPixelSize(R.dimen.chartview_divider_height); + mDividerPaint = new Paint(); + mDividerPaint.setAntiAlias(true); + mDividerPaint.setColor(DIVIDER_COLOR); + mDividerPaint.setStyle(Paint.Style.STROKE); + mDividerPaint.setStrokeWidth(mDividerWidth); + Log.i(TAG, "mDividerWidth:" + mDividerWidth); + Log.i(TAG, "mDividerHeight:" + mDividerHeight); + // Initializes the trapezoid paint. + mTrapezoidHOffset = resources.getDimension(R.dimen.chartview_trapezoid_margin_start); + mTrapezoidVOffset = resources.getDimension(R.dimen.chartview_trapezoid_margin_bottom); + mTrapezoidPaint = new Paint(); + mTrapezoidPaint.setAntiAlias(true); + mTrapezoidPaint.setColor(mTrapezoidSolidColor); + mTrapezoidPaint.setStyle(Paint.Style.FILL); + mTrapezoidPaint.setPathEffect( + new CornerPathEffect( + resources.getDimensionPixelSize(R.dimen.chartview_trapezoid_radius))); + // Initializes for drawing text information. + mTextPadding = resources.getDimensionPixelSize(R.dimen.chartview_text_padding); + } + + private void drawHorizontalDividers(Canvas canvas) { + final int width = getWidth() - mIndent.right; + final int height = getHeight() - mIndent.top - mIndent.bottom; + // Draws the top divider line for 100% curve. + float offsetY = mIndent.top + mDividerWidth * .5f; + canvas.drawLine(0, offsetY, width, offsetY, mDividerPaint); + drawPercentage(canvas, /*index=*/ 0, offsetY); + + // Draws the center divider line for 50% curve. + final float availableSpace = + height - mDividerWidth * 2 - mTrapezoidVOffset - mDividerHeight; + offsetY = mIndent.top + mDividerWidth + availableSpace * .5f; + canvas.drawLine(0, offsetY, width, offsetY, mDividerPaint); + drawPercentage(canvas, /*index=*/ 1, offsetY); + + // Draws the bottom divider line for 0% curve. + offsetY = mIndent.top + (height - mDividerHeight - mDividerWidth * .5f); + canvas.drawLine(0, offsetY, width, offsetY, mDividerPaint); + drawPercentage(canvas, /*index=*/ 2, offsetY); + } + + private void drawPercentage(Canvas canvas, int index, float offsetY) { + if (mTextPaint != null) { + canvas.drawText( + mPercentages[index], + getWidth() - mPercentageBounds[index].width() + - mPercentageBounds[index].left, + offsetY + mPercentageBounds[index].height() * .5f, + mTextPaint); + } + } + + private void drawVerticalDividers(Canvas canvas) { + final int width = getWidth() - mIndent.right; + final int dividerCount = mTrapezoidCount + 1; + final float dividerSpace = dividerCount * mDividerWidth; + final float unitWidth = (width - dividerSpace) / (float) mTrapezoidCount; + final float bottomY = getHeight() - mIndent.bottom; + final float startY = bottomY - mDividerHeight; + final float trapezoidSlotOffset = mTrapezoidHOffset + mDividerWidth * .5f; + // Draws each vertical dividers. + float startX = mDividerWidth * .5f; + for (int index = 0; index < dividerCount; index++) { + canvas.drawLine(startX, startY, startX, bottomY, mDividerPaint); + final float nextX = startX + mDividerWidth + unitWidth; + // Updates the trapezoid slots for drawing. + if (index < mTrapezoidSlots.length) { + mTrapezoidSlots[index].mLeft = round(startX + trapezoidSlotOffset); + mTrapezoidSlots[index].mRight = round(nextX - trapezoidSlotOffset); + } + startX = nextX; + } + // Draws the timestamp slot information. + if (mTimestamps != null) { + final float[] xOffsets = new float[DEFAULT_TIMESTAMP_COUNT]; + final float baselineX = mDividerWidth * .5f; + final float offsetX = mDividerWidth + unitWidth; + final int slotBarOffset = (/*total 12 bars*/ 12) / TIMESTAMP_GAPS_COUNT; + for (int index = 0; index < DEFAULT_TIMESTAMP_COUNT; index++) { + xOffsets[index] = baselineX + index * offsetX * slotBarOffset; + } + drawTimestamp(canvas, xOffsets); + } + } + + private void drawTimestamp(Canvas canvas, float[] xOffsets) { + // Draws the 1st timestamp info. + canvas.drawText( + mTimestamps[0], + xOffsets[0] - mTimestampsBounds[0].left, + getTimestampY(0), mTextPaint); + final int latestIndex = DEFAULT_TIMESTAMP_COUNT - 1; + // Draws the last timestamp info. + canvas.drawText( + mTimestamps[latestIndex], + xOffsets[latestIndex] - mTimestampsBounds[latestIndex].width() + - mTimestampsBounds[latestIndex].left, + getTimestampY(latestIndex), mTextPaint); + // Draws the rest of timestamp info since it is located in the center. + for (int index = 1; index <= DEFAULT_TIMESTAMP_COUNT - 2; index++) { + canvas.drawText( + mTimestamps[index], + xOffsets[index] + - (mTimestampsBounds[index].width() - mTimestampsBounds[index].left) + * .5f, + getTimestampY(index), mTextPaint); + + } + } + + private int getTimestampY(int index) { + return getHeight() - mTimestampsBounds[index].height() + + (mTimestampsBounds[index].height() + mTimestampsBounds[index].top) + + round(mTextPadding * 1.5f); + } + + private void drawTrapezoids(Canvas canvas) { + // Ignores invalid trapezoid data. + if (mLevels == null) { + return; + } + final float trapezoidBottom = + getHeight() - mIndent.bottom - mDividerHeight - mDividerWidth + - mTrapezoidVOffset; + final float availableSpace = trapezoidBottom - mDividerWidth * .5f - mIndent.top; + final float unitHeight = availableSpace / 100f; + // Draws all trapezoid shapes into the canvas. + final Path trapezoidPath = new Path(); + Path trapezoidCurvePath = null; + for (int index = 0; index < mTrapezoidCount; index++) { + // Not draws the trapezoid for corner or not initialization cases. + if (!isValidToDraw(index)) { + if (mTrapezoidCurvePaint != null && trapezoidCurvePath != null) { + canvas.drawPath(trapezoidCurvePath, mTrapezoidCurvePaint); + trapezoidCurvePath = null; + } + continue; + } + // Configures the trapezoid paint color. + final int trapezoidColor = + !mIsSlotsClickabled + ? mTrapezoidColor + : mSelectedIndex == index || mSelectedIndex == SELECTED_INDEX_ALL + ? mTrapezoidSolidColor : mTrapezoidColor; + final boolean isHoverState = + mIsSlotsClickabled && mHoveredIndex == index && isValidToDraw(mHoveredIndex); + mTrapezoidPaint.setColor(isHoverState ? mTrapezoidHoverColor : trapezoidColor); + + final float leftTop = round(trapezoidBottom - mLevels[index] * unitHeight); + final float rightTop = round(trapezoidBottom - mLevels[index + 1] * unitHeight); + trapezoidPath.reset(); + trapezoidPath.moveTo(mTrapezoidSlots[index].mLeft, trapezoidBottom); + trapezoidPath.lineTo(mTrapezoidSlots[index].mLeft, leftTop); + trapezoidPath.lineTo(mTrapezoidSlots[index].mRight, rightTop); + trapezoidPath.lineTo(mTrapezoidSlots[index].mRight, trapezoidBottom); + // A tricky way to make the trapezoid shape drawing the rounded corner. + trapezoidPath.lineTo(mTrapezoidSlots[index].mLeft, trapezoidBottom); + trapezoidPath.lineTo(mTrapezoidSlots[index].mLeft, leftTop); + // Draws the trapezoid shape into canvas. + canvas.drawPath(trapezoidPath, mTrapezoidPaint); + + // Generates path for non-clickable trapezoid curve. + if (mTrapezoidCurvePaint != null) { + if (trapezoidCurvePath == null) { + trapezoidCurvePath = new Path(); + trapezoidCurvePath.moveTo(mTrapezoidSlots[index].mLeft, leftTop); + } else { + trapezoidCurvePath.lineTo(mTrapezoidSlots[index].mLeft, leftTop); + } + trapezoidCurvePath.lineTo(mTrapezoidSlots[index].mRight, rightTop); + } + } + // Draws the trapezoid curve for non-clickable case. + if (mTrapezoidCurvePaint != null && trapezoidCurvePath != null) { + canvas.drawPath(trapezoidCurvePath, mTrapezoidCurvePaint); + trapezoidCurvePath = null; + } + } + + // Searches the corresponding trapezoid index from x location. + private int getTrapezoidIndex(float x) { + for (int index = 0; index < mTrapezoidSlots.length; index++) { + final TrapezoidSlot slot = mTrapezoidSlots[index]; + if (x >= slot.mLeft - mTrapezoidHOffset + && x <= slot.mRight + mTrapezoidHOffset) { + return index; + } + } + return SELECTED_INDEX_INVALID; + } + + private boolean isValidToDraw(int trapezoidIndex) { + return mLevels != null + && trapezoidIndex >= 0 + && trapezoidIndex < mLevels.length - 1 + && mLevels[trapezoidIndex] != 0 + && mLevels[trapezoidIndex + 1] != 0; + } + + private static String[] getPercentages() { + return new String[]{ + formatPercentage(/*percentage=*/ 100, /*round=*/ true), + formatPercentage(/*percentage=*/ 50, /*round=*/ true), + formatPercentage(/*percentage=*/ 0, /*round=*/ true)}; + } + + @VisibleForTesting + static boolean isAccessibilityEnabled(Context context) { + final AccessibilityManager accessibilityManager = + context.getSystemService(AccessibilityManager.class); + if (!accessibilityManager.isEnabled()) { + return false; + } + final List serviceInfoList = + accessibilityManager.getEnabledAccessibilityServiceList( + AccessibilityServiceInfo.FEEDBACK_SPOKEN + | AccessibilityServiceInfo.FEEDBACK_GENERIC); + for (AccessibilityServiceInfo info : serviceInfoList) { + for (String serviceName : ACCESSIBILITY_SERVICE_NAMES) { + final String serviceId = info.getId(); + if (serviceId != null && serviceId.contains(serviceName)) { + Log.d(TAG, "acccessibilityEnabled:" + serviceId); + return true; + } + } + } + return false; + } + + // A container class for each trapezoid left and right location. + private static final class TrapezoidSlot { + public float mLeft; + public float mRight; + + @Override + public String toString() { + return String.format(Locale.US, "TrapezoidSlot[%f,%f]", mLeft, mRight); + } + } +} diff --git a/tests/robotests/src/com/android/settings/fuelgauge/batteryusage/BatteryChartPreferenceControllerV2Test.java b/tests/robotests/src/com/android/settings/fuelgauge/batteryusage/BatteryChartPreferenceControllerV2Test.java new file mode 100644 index 00000000000..e9d62680a0a --- /dev/null +++ b/tests/robotests/src/com/android/settings/fuelgauge/batteryusage/BatteryChartPreferenceControllerV2Test.java @@ -0,0 +1,700 @@ +/* + * 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.ArgumentMatchers.any; +import static org.mockito.Matchers.anyLong; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; + +import android.app.settings.SettingsEnums; +import android.content.ContentValues; +import android.content.Context; +import android.content.res.Resources; +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.os.LocaleList; +import android.text.format.DateUtils; + +import androidx.preference.Preference; +import androidx.preference.PreferenceCategory; +import androidx.preference.PreferenceGroup; + +import com.android.settings.SettingsActivity; +import com.android.settings.core.InstrumentedPreferenceFragment; +import com.android.settings.fuelgauge.BatteryUtils; +import com.android.settings.testutils.FakeFeatureFactory; +import com.android.settingslib.core.instrumentation.MetricsFeatureProvider; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; + +@RunWith(RobolectricTestRunner.class) +public final class BatteryChartPreferenceControllerV2Test { + private static final String PREF_KEY = "pref_key"; + private static final String PREF_SUMMARY = "fake preference summary"; + private static final int DESIRED_HISTORY_SIZE = + BatteryChartPreferenceControllerV2.DESIRED_HISTORY_SIZE; + + @Mock + private InstrumentedPreferenceFragment mFragment; + @Mock + private SettingsActivity mSettingsActivity; + @Mock + private PreferenceGroup mAppListGroup; + @Mock + private Drawable mDrawable; + @Mock + private BatteryHistEntry mBatteryHistEntry; + @Mock + private BatteryChartViewV2 mBatteryChartView; + @Mock + private PowerGaugePreference mPowerGaugePreference; + @Mock + private BatteryUtils mBatteryUtils; + + private Context mContext; + private FakeFeatureFactory mFeatureFactory; + private BatteryDiffEntry mBatteryDiffEntry; + private MetricsFeatureProvider mMetricsFeatureProvider; + private BatteryChartPreferenceControllerV2 mBatteryChartPreferenceController; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + Locale.setDefault(new Locale("en_US")); + org.robolectric.shadows.ShadowSettings.set24HourTimeFormat(false); + mFeatureFactory = FakeFeatureFactory.setupForTest(); + mMetricsFeatureProvider = mFeatureFactory.metricsFeatureProvider; + mContext = spy(RuntimeEnvironment.application); + final Resources resources = spy(mContext.getResources()); + resources.getConfiguration().setLocales(new LocaleList(new Locale("en_US"))); + doReturn(resources).when(mContext).getResources(); + doReturn(new String[]{"com.android.googlequicksearchbox"}) + .when(mFeatureFactory.powerUsageFeatureProvider) + .getHideApplicationSummary(mContext); + doReturn(new String[]{"com.android.gms.persistent"}) + .when(mFeatureFactory.powerUsageFeatureProvider) + .getHideApplicationEntries(mContext); + mBatteryChartPreferenceController = createController(); + mBatteryChartPreferenceController.mPrefContext = mContext; + mBatteryChartPreferenceController.mAppListPrefGroup = mAppListGroup; + mBatteryChartPreferenceController.mBatteryChartView = mBatteryChartView; + mBatteryDiffEntry = new BatteryDiffEntry( + mContext, + /*foregroundUsageTimeInMs=*/ 1, + /*backgroundUsageTimeInMs=*/ 2, + /*consumePower=*/ 3, + mBatteryHistEntry); + mBatteryDiffEntry = spy(mBatteryDiffEntry); + // Adds fake testing data. + BatteryDiffEntry.sResourceCache.put( + "fakeBatteryDiffEntryKey", + new BatteryEntry.NameAndIcon("fakeName", /*icon=*/ null, /*iconId=*/ 1)); + mBatteryChartPreferenceController.setBatteryHistoryMap( + createBatteryHistoryMap()); + } + + @Test + public void testOnDestroy_activityIsChanging_clearBatteryEntryCache() { + doReturn(true).when(mSettingsActivity).isChangingConfigurations(); + // Ensures the testing environment is correct. + assertThat(BatteryDiffEntry.sResourceCache).hasSize(1); + + mBatteryChartPreferenceController.onDestroy(); + assertThat(BatteryDiffEntry.sResourceCache).isEmpty(); + } + + @Test + public void testOnDestroy_activityIsNotChanging_notClearBatteryEntryCache() { + doReturn(false).when(mSettingsActivity).isChangingConfigurations(); + // Ensures the testing environment is correct. + assertThat(BatteryDiffEntry.sResourceCache).hasSize(1); + + mBatteryChartPreferenceController.onDestroy(); + assertThat(BatteryDiffEntry.sResourceCache).isNotEmpty(); + } + + @Test + public void testOnDestroy_clearPreferenceCache() { + // Ensures the testing environment is correct. + mBatteryChartPreferenceController.mPreferenceCache.put( + PREF_KEY, mPowerGaugePreference); + assertThat(mBatteryChartPreferenceController.mPreferenceCache).hasSize(1); + + mBatteryChartPreferenceController.onDestroy(); + // Verifies the result after onDestroy. + assertThat(mBatteryChartPreferenceController.mPreferenceCache).isEmpty(); + } + + @Test + public void testOnDestroy_removeAllPreferenceFromPreferenceGroup() { + mBatteryChartPreferenceController.onDestroy(); + verify(mAppListGroup).removeAll(); + } + + @Test + public void testSetBatteryHistoryMap_createExpectedKeysAndLevels() { + mBatteryChartPreferenceController.setBatteryHistoryMap( + createBatteryHistoryMap()); + + // Verifies the created battery keys array. + for (int index = 0; index < DESIRED_HISTORY_SIZE; index++) { + assertThat(mBatteryChartPreferenceController.mBatteryHistoryKeys[index]) + // These values is are calculated by hand from createBatteryHistoryMap(). + .isEqualTo(index + 1); + } + // Verifies the created battery levels array. + for (int index = 0; index < 13; index++) { + assertThat(mBatteryChartPreferenceController.mBatteryHistoryLevels[index]) + // These values is are calculated by hand from createBatteryHistoryMap(). + .isEqualTo(100 - index * 2); + } + assertThat(mBatteryChartPreferenceController.mBatteryIndexedMap).hasSize(13); + } + + @Test + public void testSetBatteryHistoryMap_largeSize_createExpectedKeysAndLevels() { + mBatteryChartPreferenceController.setBatteryHistoryMap( + createBatteryHistoryMap()); + + // Verifies the created battery keys array. + for (int index = 0; index < DESIRED_HISTORY_SIZE; index++) { + assertThat(mBatteryChartPreferenceController.mBatteryHistoryKeys[index]) + // These values is are calculated by hand from createBatteryHistoryMap(). + .isEqualTo(index + 1); + } + // Verifies the created battery levels array. + for (int index = 0; index < 13; index++) { + assertThat(mBatteryChartPreferenceController.mBatteryHistoryLevels[index]) + // These values is are calculated by hand from createBatteryHistoryMap(). + .isEqualTo(100 - index * 2); + } + assertThat(mBatteryChartPreferenceController.mBatteryIndexedMap).hasSize(13); + } + + @Test + public void testRefreshUi_batteryIndexedMapIsNull_ignoreRefresh() { + mBatteryChartPreferenceController.setBatteryHistoryMap(null); + assertThat(mBatteryChartPreferenceController.refreshUi( + /*trapezoidIndex=*/ 1, /*isForce=*/ false)).isFalse(); + } + + @Test + public void testRefreshUi_batteryChartViewIsNull_ignoreRefresh() { + mBatteryChartPreferenceController.mBatteryChartView = null; + assertThat(mBatteryChartPreferenceController.refreshUi( + /*trapezoidIndex=*/ 1, /*isForce=*/ false)).isFalse(); + } + + @Test + public void testRefreshUi_trapezoidIndexIsNotChanged_ignoreRefresh() { + final int trapezoidIndex = 1; + mBatteryChartPreferenceController.mTrapezoidIndex = trapezoidIndex; + assertThat(mBatteryChartPreferenceController.refreshUi( + trapezoidIndex, /*isForce=*/ false)).isFalse(); + } + + @Test + public void testRefreshUi_forceUpdate_refreshUi() { + final int trapezoidIndex = 1; + mBatteryChartPreferenceController.mTrapezoidIndex = trapezoidIndex; + assertThat(mBatteryChartPreferenceController.refreshUi( + trapezoidIndex, /*isForce=*/ true)).isTrue(); + } + + @Test + public void testForceRefreshUi_updateTrapezoidIndexIntoSelectAll() { + mBatteryChartPreferenceController.mTrapezoidIndex = + BatteryChartViewV2.SELECTED_INDEX_INVALID; + mBatteryChartPreferenceController.setBatteryHistoryMap( + createBatteryHistoryMap()); + + assertThat(mBatteryChartPreferenceController.mTrapezoidIndex) + .isEqualTo(BatteryChartViewV2.SELECTED_INDEX_ALL); + } + + @Test + public void testRemoveAndCacheAllPrefs_emptyContent_ignoreRemoveAll() { + final int trapezoidIndex = 1; + doReturn(0).when(mAppListGroup).getPreferenceCount(); + + mBatteryChartPreferenceController.refreshUi( + trapezoidIndex, /*isForce=*/ true); + verify(mAppListGroup, never()).removeAll(); + } + + @Test + public void testRemoveAndCacheAllPrefs_buildCacheAndRemoveAllPreference() { + final int trapezoidIndex = 1; + doReturn(1).when(mAppListGroup).getPreferenceCount(); + doReturn(mPowerGaugePreference).when(mAppListGroup).getPreference(0); + doReturn(PREF_KEY).when(mPowerGaugePreference).getKey(); + // Ensures the testing data is correct. + assertThat(mBatteryChartPreferenceController.mPreferenceCache).isEmpty(); + + mBatteryChartPreferenceController.refreshUi( + trapezoidIndex, /*isForce=*/ true); + + assertThat(mBatteryChartPreferenceController.mPreferenceCache.get(PREF_KEY)) + .isEqualTo(mPowerGaugePreference); + verify(mAppListGroup).removeAll(); + } + + @Test + public void testAddPreferenceToScreen_emptyContent_ignoreAddPreference() { + mBatteryChartPreferenceController.addPreferenceToScreen( + new ArrayList()); + verify(mAppListGroup, never()).addPreference(any()); + } + + @Test + public void testAddPreferenceToScreen_addPreferenceIntoScreen() { + final String appLabel = "fake app label"; + doReturn(1).when(mAppListGroup).getPreferenceCount(); + doReturn(mDrawable).when(mBatteryDiffEntry).getAppIcon(); + doReturn(appLabel).when(mBatteryDiffEntry).getAppLabel(); + doReturn(PREF_KEY).when(mBatteryHistEntry).getKey(); + doReturn(null).when(mAppListGroup).findPreference(PREF_KEY); + doReturn(false).when(mBatteryDiffEntry).validForRestriction(); + + mBatteryChartPreferenceController.addPreferenceToScreen( + Arrays.asList(mBatteryDiffEntry)); + + // Verifies the preference cache. + final PowerGaugePreference pref = + (PowerGaugePreference) mBatteryChartPreferenceController.mPreferenceCache + .get(PREF_KEY); + assertThat(pref).isNotNull(); + // Verifies the added preference configuration. + verify(mAppListGroup).addPreference(pref); + assertThat(pref.getKey()).isEqualTo(PREF_KEY); + assertThat(pref.getTitle()).isEqualTo(appLabel); + assertThat(pref.getIcon()).isEqualTo(mDrawable); + assertThat(pref.getOrder()).isEqualTo(1); + assertThat(pref.getBatteryDiffEntry()).isSameInstanceAs(mBatteryDiffEntry); + assertThat(pref.isSingleLineTitle()).isTrue(); + assertThat(pref.isEnabled()).isFalse(); + } + + @Test + public void testAddPreferenceToScreen_alreadyInScreen_notAddPreferenceAgain() { + final String appLabel = "fake app label"; + doReturn(1).when(mAppListGroup).getPreferenceCount(); + doReturn(mDrawable).when(mBatteryDiffEntry).getAppIcon(); + doReturn(appLabel).when(mBatteryDiffEntry).getAppLabel(); + doReturn(PREF_KEY).when(mBatteryHistEntry).getKey(); + doReturn(mPowerGaugePreference).when(mAppListGroup).findPreference(PREF_KEY); + + mBatteryChartPreferenceController.addPreferenceToScreen( + Arrays.asList(mBatteryDiffEntry)); + + verify(mAppListGroup, never()).addPreference(any()); + } + + @Test + public void testHandlePreferenceTreeiClick_notPowerGaugePreference_returnFalse() { + assertThat(mBatteryChartPreferenceController.handlePreferenceTreeClick(mAppListGroup)) + .isFalse(); + + verify(mMetricsFeatureProvider, never()) + .action(mContext, SettingsEnums.ACTION_BATTERY_USAGE_APP_ITEM); + verify(mMetricsFeatureProvider, never()) + .action(mContext, SettingsEnums.ACTION_BATTERY_USAGE_SYSTEM_ITEM); + } + + @Test + public void testHandlePreferenceTreeClick_forAppEntry_returnTrue() { + doReturn(false).when(mBatteryHistEntry).isAppEntry(); + doReturn(mBatteryDiffEntry).when(mPowerGaugePreference).getBatteryDiffEntry(); + + assertThat(mBatteryChartPreferenceController.handlePreferenceTreeClick( + mPowerGaugePreference)).isTrue(); + verify(mMetricsFeatureProvider) + .action( + SettingsEnums.OPEN_BATTERY_USAGE, + SettingsEnums.ACTION_BATTERY_USAGE_SYSTEM_ITEM, + SettingsEnums.OPEN_BATTERY_USAGE, + /* package name */ "none", + /* percentage of total */ 0); + } + + @Test + public void testHandlePreferenceTreeClick_forSystemEntry_returnTrue() { + mBatteryChartPreferenceController.mBatteryUtils = mBatteryUtils; + doReturn(true).when(mBatteryHistEntry).isAppEntry(); + doReturn(mBatteryDiffEntry).when(mPowerGaugePreference).getBatteryDiffEntry(); + + assertThat(mBatteryChartPreferenceController.handlePreferenceTreeClick( + mPowerGaugePreference)).isTrue(); + verify(mMetricsFeatureProvider) + .action( + SettingsEnums.OPEN_BATTERY_USAGE, + SettingsEnums.ACTION_BATTERY_USAGE_APP_ITEM, + SettingsEnums.OPEN_BATTERY_USAGE, + /* package name */ "none", + /* percentage of total */ 0); + } + + @Test + public void testSetPreferenceSummary_setNullContentIfTotalUsageTimeIsZero() { + final PowerGaugePreference pref = new PowerGaugePreference(mContext); + pref.setSummary(PREF_SUMMARY); + + mBatteryChartPreferenceController.setPreferenceSummary( + pref, createBatteryDiffEntry( + /*foregroundUsageTimeInMs=*/ 0, + /*backgroundUsageTimeInMs=*/ 0)); + assertThat(pref.getSummary()).isNull(); + } + + @Test + public void testSetPreferenceSummary_setBackgroundUsageTimeOnly() { + final PowerGaugePreference pref = new PowerGaugePreference(mContext); + pref.setSummary(PREF_SUMMARY); + + mBatteryChartPreferenceController.setPreferenceSummary( + pref, createBatteryDiffEntry( + /*foregroundUsageTimeInMs=*/ 0, + /*backgroundUsageTimeInMs=*/ DateUtils.MINUTE_IN_MILLIS)); + assertThat(pref.getSummary()).isEqualTo("Background: 1 min"); + } + + @Test + public void testSetPreferenceSummary_setTotalUsageTimeLessThanAMinute() { + final PowerGaugePreference pref = new PowerGaugePreference(mContext); + pref.setSummary(PREF_SUMMARY); + + mBatteryChartPreferenceController.setPreferenceSummary( + pref, createBatteryDiffEntry( + /*foregroundUsageTimeInMs=*/ 100, + /*backgroundUsageTimeInMs=*/ 200)); + assertThat(pref.getSummary()).isEqualTo("Total: less than a min"); + } + + @Test + public void testSetPreferenceSummary_setTotalTimeIfBackgroundTimeLessThanAMinute() { + final PowerGaugePreference pref = new PowerGaugePreference(mContext); + pref.setSummary(PREF_SUMMARY); + + mBatteryChartPreferenceController.setPreferenceSummary( + pref, createBatteryDiffEntry( + /*foregroundUsageTimeInMs=*/ DateUtils.MINUTE_IN_MILLIS, + /*backgroundUsageTimeInMs=*/ 200)); + assertThat(pref.getSummary()) + .isEqualTo("Total: 1 min\nBackground: less than a min"); + } + + @Test + public void testSetPreferenceSummary_setTotalAndBackgroundUsageTime() { + final PowerGaugePreference pref = new PowerGaugePreference(mContext); + pref.setSummary(PREF_SUMMARY); + + mBatteryChartPreferenceController.setPreferenceSummary( + pref, createBatteryDiffEntry( + /*foregroundUsageTimeInMs=*/ DateUtils.MINUTE_IN_MILLIS, + /*backgroundUsageTimeInMs=*/ DateUtils.MINUTE_IN_MILLIS)); + assertThat(pref.getSummary()).isEqualTo("Total: 2 min\nBackground: 1 min"); + } + + @Test + public void testSetPreferenceSummary_notAllowShownPackage_setSummayAsNull() { + final PowerGaugePreference pref = new PowerGaugePreference(mContext); + pref.setSummary(PREF_SUMMARY); + final BatteryDiffEntry batteryDiffEntry = + spy(createBatteryDiffEntry( + /*foregroundUsageTimeInMs=*/ DateUtils.MINUTE_IN_MILLIS, + /*backgroundUsageTimeInMs=*/ DateUtils.MINUTE_IN_MILLIS)); + doReturn("com.android.googlequicksearchbox").when(batteryDiffEntry) + .getPackageName(); + + mBatteryChartPreferenceController.setPreferenceSummary(pref, batteryDiffEntry); + assertThat(pref.getSummary()).isNull(); + } + + @Test + public void testValidateUsageTime_returnTrueIfBatteryDiffEntryIsValid() { + assertThat(BatteryChartPreferenceControllerV2.validateUsageTime( + createBatteryDiffEntry( + /*foregroundUsageTimeInMs=*/ DateUtils.MINUTE_IN_MILLIS, + /*backgroundUsageTimeInMs=*/ DateUtils.MINUTE_IN_MILLIS))) + .isTrue(); + } + + @Test + public void testValidateUsageTime_foregroundTimeExceedThreshold_returnFalse() { + assertThat(BatteryChartPreferenceControllerV2.validateUsageTime( + createBatteryDiffEntry( + /*foregroundUsageTimeInMs=*/ DateUtils.HOUR_IN_MILLIS * 3, + /*backgroundUsageTimeInMs=*/ 0))) + .isFalse(); + } + + @Test + public void testValidateUsageTime_backgroundTimeExceedThreshold_returnFalse() { + assertThat(BatteryChartPreferenceControllerV2.validateUsageTime( + createBatteryDiffEntry( + /*foregroundUsageTimeInMs=*/ 0, + /*backgroundUsageTimeInMs=*/ DateUtils.HOUR_IN_MILLIS * 3))) + .isFalse(); + } + + @Test + public void testOnExpand_expandedIsTrue_addSystemEntriesToPreferenceGroup() { + doReturn(1).when(mAppListGroup).getPreferenceCount(); + mBatteryChartPreferenceController.mSystemEntries.add(mBatteryDiffEntry); + doReturn("label").when(mBatteryDiffEntry).getAppLabel(); + doReturn(mDrawable).when(mBatteryDiffEntry).getAppIcon(); + doReturn(PREF_KEY).when(mBatteryHistEntry).getKey(); + + mBatteryChartPreferenceController.onExpand(/*isExpanded=*/ true); + + final ArgumentCaptor captor = ArgumentCaptor.forClass(Preference.class); + verify(mAppListGroup).addPreference(captor.capture()); + // Verifies the added preference. + assertThat(captor.getValue().getKey()).isEqualTo(PREF_KEY); + verify(mMetricsFeatureProvider) + .action( + mContext, + SettingsEnums.ACTION_BATTERY_USAGE_EXPAND_ITEM, + true /*isExpanded*/); + } + + @Test + public void testOnExpand_expandedIsFalse_removeSystemEntriesFromPreferenceGroup() { + doReturn(PREF_KEY).when(mBatteryHistEntry).getKey(); + doReturn(mPowerGaugePreference).when(mAppListGroup).findPreference(PREF_KEY); + mBatteryChartPreferenceController.mSystemEntries.add(mBatteryDiffEntry); + // Verifies the cache is empty first. + assertThat(mBatteryChartPreferenceController.mPreferenceCache).isEmpty(); + + mBatteryChartPreferenceController.onExpand(/*isExpanded=*/ false); + + verify(mAppListGroup).findPreference(PREF_KEY); + verify(mAppListGroup).removePreference(mPowerGaugePreference); + assertThat(mBatteryChartPreferenceController.mPreferenceCache).hasSize(1); + verify(mMetricsFeatureProvider) + .action( + mContext, + SettingsEnums.ACTION_BATTERY_USAGE_EXPAND_ITEM, + false /*isExpanded*/); + } + + @Test + public void testOnSelect_selectSpecificTimeSlot_logMetric() { + mBatteryChartPreferenceController.onSelect(1 /*slot index*/); + + verify(mMetricsFeatureProvider) + .action(mContext, SettingsEnums.ACTION_BATTERY_USAGE_TIME_SLOT); + } + + @Test + public void testOnSelect_selectAll_logMetric() { + mBatteryChartPreferenceController.onSelect( + BatteryChartViewV2.SELECTED_INDEX_ALL /*slot index*/); + + verify(mMetricsFeatureProvider) + .action(mContext, SettingsEnums.ACTION_BATTERY_USAGE_SHOW_ALL); + } + + @Test + public void testRefreshCategoryTitle_setHourIntoBothTitleTextView() { + mBatteryChartPreferenceController = createController(); + setUpBatteryHistoryKeys(); + mBatteryChartPreferenceController.mAppListPrefGroup = + spy(new PreferenceCategory(mContext)); + mBatteryChartPreferenceController.mExpandDividerPreference = + spy(new ExpandDividerPreference(mContext)); + // Simulates select the first slot. + mBatteryChartPreferenceController.mTrapezoidIndex = 0; + + mBatteryChartPreferenceController.refreshCategoryTitle(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(String.class); + // Verifies the title in the preference group. + verify(mBatteryChartPreferenceController.mAppListPrefGroup) + .setTitle(captor.capture()); + assertThat(captor.getValue()).isNotEqualTo("App usage for past 24 hr"); + // Verifies the title in the expandable divider. + captor = ArgumentCaptor.forClass(String.class); + verify(mBatteryChartPreferenceController.mExpandDividerPreference) + .setTitle(captor.capture()); + assertThat(captor.getValue()).isNotEqualTo("System usage for past 24 hr"); + } + + @Test + public void testRefreshCategoryTitle_setLast24HrIntoBothTitleTextView() { + mBatteryChartPreferenceController = createController(); + mBatteryChartPreferenceController.mAppListPrefGroup = + spy(new PreferenceCategory(mContext)); + mBatteryChartPreferenceController.mExpandDividerPreference = + spy(new ExpandDividerPreference(mContext)); + // Simulates select all condition. + mBatteryChartPreferenceController.mTrapezoidIndex = + BatteryChartViewV2.SELECTED_INDEX_ALL; + + mBatteryChartPreferenceController.refreshCategoryTitle(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(String.class); + // Verifies the title in the preference group. + verify(mBatteryChartPreferenceController.mAppListPrefGroup) + .setTitle(captor.capture()); + assertThat(captor.getValue()) + .isEqualTo("App usage for past 24 hr"); + // Verifies the title in the expandable divider. + captor = ArgumentCaptor.forClass(String.class); + verify(mBatteryChartPreferenceController.mExpandDividerPreference) + .setTitle(captor.capture()); + assertThat(captor.getValue()) + .isEqualTo("System usage for past 24 hr"); + } + + @Test + public void testSetTimestampLabel_nullBatteryHistoryKeys_ignore() { + mBatteryChartPreferenceController = createController(); + mBatteryChartPreferenceController.mBatteryHistoryKeys = null; + mBatteryChartPreferenceController.mBatteryChartView = + spy(new BatteryChartViewV2(mContext)); + mBatteryChartPreferenceController.setTimestampLabel(); + + verify(mBatteryChartPreferenceController.mBatteryChartView, never()) + .setLatestTimestamp(anyLong()); + } + + @Test + public void testSetTimestampLabel_setExpectedTimestampData() { + mBatteryChartPreferenceController = createController(); + mBatteryChartPreferenceController.mBatteryChartView = + spy(new BatteryChartViewV2(mContext)); + setUpBatteryHistoryKeys(); + + mBatteryChartPreferenceController.setTimestampLabel(); + + verify(mBatteryChartPreferenceController.mBatteryChartView) + .setLatestTimestamp(1619247636826L); + } + + @Test + public void testSetTimestampLabel_withoutValidTimestamp_setExpectedTimestampData() { + mBatteryChartPreferenceController = createController(); + mBatteryChartPreferenceController.mBatteryChartView = + spy(new BatteryChartViewV2(mContext)); + mBatteryChartPreferenceController.mBatteryHistoryKeys = new long[]{0L}; + + mBatteryChartPreferenceController.setTimestampLabel(); + + verify(mBatteryChartPreferenceController.mBatteryChartView) + .setLatestTimestamp(anyLong()); + } + + @Test + public void testOnSaveInstanceState_restoreSelectedIndexAndExpandState() { + final int expectedIndex = 1; + final boolean isExpanded = true; + final Bundle bundle = new Bundle(); + mBatteryChartPreferenceController.mTrapezoidIndex = expectedIndex; + mBatteryChartPreferenceController.mIsExpanded = isExpanded; + mBatteryChartPreferenceController.onSaveInstanceState(bundle); + // Replaces the original controller with other values. + mBatteryChartPreferenceController.mTrapezoidIndex = -1; + mBatteryChartPreferenceController.mIsExpanded = false; + + mBatteryChartPreferenceController.onCreate(bundle); + mBatteryChartPreferenceController.setBatteryHistoryMap( + createBatteryHistoryMap()); + + assertThat(mBatteryChartPreferenceController.mTrapezoidIndex) + .isEqualTo(expectedIndex); + assertThat(mBatteryChartPreferenceController.mIsExpanded).isTrue(); + } + + @Test + public void testIsValidToShowSummary_returnExpectedResult() { + assertThat(mBatteryChartPreferenceController + .isValidToShowSummary("com.google.android.apps.scone")) + .isTrue(); + + // Verifies the item which is defined in the array list. + assertThat(mBatteryChartPreferenceController + .isValidToShowSummary("com.android.googlequicksearchbox")) + .isFalse(); + } + + @Test + public void testIsValidToShowEntry_returnExpectedResult() { + assertThat(mBatteryChartPreferenceController + .isValidToShowEntry("com.google.android.apps.scone")) + .isTrue(); + + // Verifies the items which are defined in the array list. + assertThat(mBatteryChartPreferenceController + .isValidToShowEntry("com.android.gms.persistent")) + .isFalse(); + } + + private static Map> createBatteryHistoryMap() { + final Map> batteryHistoryMap = new HashMap<>(); + for (int index = 0; index < DESIRED_HISTORY_SIZE; index++) { + final ContentValues values = new ContentValues(); + values.put("batteryLevel", Integer.valueOf(100 - index)); + final BatteryHistEntry entry = new BatteryHistEntry(values); + final Map entryMap = new HashMap<>(); + entryMap.put("fake_entry_key" + index, entry); + batteryHistoryMap.put(Long.valueOf(index + 1), entryMap); + } + return batteryHistoryMap; + } + + private BatteryDiffEntry createBatteryDiffEntry( + long foregroundUsageTimeInMs, long backgroundUsageTimeInMs) { + return new BatteryDiffEntry( + mContext, foregroundUsageTimeInMs, backgroundUsageTimeInMs, + /*consumePower=*/ 0, mBatteryHistEntry); + } + + private void setUpBatteryHistoryKeys() { + mBatteryChartPreferenceController.mBatteryHistoryKeys = + new long[]{1619196786769L, 0L, 1619247636826L}; + ConvertUtils.utcToLocalTimeHour( + mContext, /*timestamp=*/ 0, /*is24HourFormat=*/ false); + } + + private BatteryChartPreferenceControllerV2 createController() { + final BatteryChartPreferenceControllerV2 controller = + new BatteryChartPreferenceControllerV2( + mContext, "app_list", /*lifecycle=*/ null, + mSettingsActivity, mFragment); + controller.mPrefContext = mContext; + return controller; + } +} diff --git a/tests/robotests/src/com/android/settings/fuelgauge/batteryusage/BatteryChartViewV2Test.java b/tests/robotests/src/com/android/settings/fuelgauge/batteryusage/BatteryChartViewV2Test.java new file mode 100644 index 00000000000..111019f7dd9 --- /dev/null +++ b/tests/robotests/src/com/android/settings/fuelgauge/batteryusage/BatteryChartViewV2Test.java @@ -0,0 +1,235 @@ +/* + * 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.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.accessibilityservice.AccessibilityServiceInfo; +import android.content.Context; +import android.os.LocaleList; +import android.view.accessibility.AccessibilityManager; + +import com.android.settings.fuelgauge.PowerUsageFeatureProvider; +import com.android.settings.testutils.FakeFeatureFactory; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Locale; + +@RunWith(RobolectricTestRunner.class) +public final class BatteryChartViewV2Test { + + private Context mContext; + private BatteryChartViewV2 mBatteryChartView; + private FakeFeatureFactory mFeatureFactory; + private PowerUsageFeatureProvider mPowerUsageFeatureProvider; + + @Mock + private AccessibilityServiceInfo mMockAccessibilityServiceInfo; + @Mock + private AccessibilityManager mMockAccessibilityManager; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + mFeatureFactory = FakeFeatureFactory.setupForTest(); + mPowerUsageFeatureProvider = mFeatureFactory.powerUsageFeatureProvider; + mContext = spy(RuntimeEnvironment.application); + mContext.getResources().getConfiguration().setLocales( + new LocaleList(new Locale("en_US"))); + mBatteryChartView = new BatteryChartViewV2(mContext); + doReturn(mMockAccessibilityManager).when(mContext) + .getSystemService(AccessibilityManager.class); + doReturn("TalkBackService").when(mMockAccessibilityServiceInfo).getId(); + doReturn(Arrays.asList(mMockAccessibilityServiceInfo)) + .when(mMockAccessibilityManager) + .getEnabledAccessibilityServiceList(anyInt()); + } + + @Test + public void testIsAccessibilityEnabled_disable_returnFalse() { + doReturn(false).when(mMockAccessibilityManager).isEnabled(); + assertThat(BatteryChartViewV2.isAccessibilityEnabled(mContext)).isFalse(); + } + + @Test + public void testIsAccessibilityEnabled_emptyInfo_returnFalse() { + doReturn(true).when(mMockAccessibilityManager).isEnabled(); + doReturn(new ArrayList()) + .when(mMockAccessibilityManager) + .getEnabledAccessibilityServiceList(anyInt()); + + assertThat(BatteryChartViewV2.isAccessibilityEnabled(mContext)).isFalse(); + } + + @Test + public void testIsAccessibilityEnabled_validServiceId_returnTrue() { + doReturn(true).when(mMockAccessibilityManager).isEnabled(); + assertThat(BatteryChartViewV2.isAccessibilityEnabled(mContext)).isTrue(); + } + + @Test + public void testSetSelectedIndex_invokesCallback() { + final int[] selectedIndex = new int[1]; + final int expectedIndex = 2; + mBatteryChartView.mSelectedIndex = 1; + mBatteryChartView.setOnSelectListener( + trapezoidIndex -> { + selectedIndex[0] = trapezoidIndex; + }); + + mBatteryChartView.setSelectedIndex(expectedIndex); + + assertThat(mBatteryChartView.mSelectedIndex) + .isEqualTo(expectedIndex); + assertThat(selectedIndex[0]).isEqualTo(expectedIndex); + } + + @Test + public void testSetSelectedIndex_sameIndex_notInvokesCallback() { + final int[] selectedIndex = new int[1]; + final int expectedIndex = 1; + mBatteryChartView.mSelectedIndex = expectedIndex; + mBatteryChartView.setOnSelectListener( + trapezoidIndex -> { + selectedIndex[0] = trapezoidIndex; + }); + + mBatteryChartView.setSelectedIndex(expectedIndex); + + assertThat(selectedIndex[0]).isNotEqualTo(expectedIndex); + } + + @Test + public void testClickable_isChartGraphSlotsEnabledIsFalse_notClickable() { + mBatteryChartView.setClickableForce(true); + when(mPowerUsageFeatureProvider.isChartGraphSlotsEnabled(mContext)) + .thenReturn(false); + + mBatteryChartView.onAttachedToWindow(); + assertThat(mBatteryChartView.isClickable()).isFalse(); + assertThat(mBatteryChartView.mTrapezoidCurvePaint).isNotNull(); + } + + @Test + public void testClickable_accessibilityIsDisabled_clickable() { + mBatteryChartView.setClickableForce(true); + when(mPowerUsageFeatureProvider.isChartGraphSlotsEnabled(mContext)) + .thenReturn(true); + doReturn(false).when(mMockAccessibilityManager).isEnabled(); + + mBatteryChartView.onAttachedToWindow(); + assertThat(mBatteryChartView.isClickable()).isTrue(); + assertThat(mBatteryChartView.mTrapezoidCurvePaint).isNull(); + } + + @Test + public void testClickable_accessibilityIsEnabledWithoutValidId_clickable() { + mBatteryChartView.setClickableForce(true); + when(mPowerUsageFeatureProvider.isChartGraphSlotsEnabled(mContext)) + .thenReturn(true); + doReturn(true).when(mMockAccessibilityManager).isEnabled(); + doReturn(new ArrayList()) + .when(mMockAccessibilityManager) + .getEnabledAccessibilityServiceList(anyInt()); + + mBatteryChartView.onAttachedToWindow(); + assertThat(mBatteryChartView.isClickable()).isTrue(); + assertThat(mBatteryChartView.mTrapezoidCurvePaint).isNull(); + } + + @Test + public void testClickable_accessibilityIsEnabledWithValidId_notClickable() { + mBatteryChartView.setClickableForce(true); + when(mPowerUsageFeatureProvider.isChartGraphSlotsEnabled(mContext)) + .thenReturn(true); + doReturn(true).when(mMockAccessibilityManager).isEnabled(); + + mBatteryChartView.onAttachedToWindow(); + assertThat(mBatteryChartView.isClickable()).isFalse(); + assertThat(mBatteryChartView.mTrapezoidCurvePaint).isNotNull(); + } + + @Test + public void testClickable_restoreFromNonClickableState() { + final int[] levels = new int[13]; + for (int index = 0; index < levels.length; index++) { + levels[index] = index + 1; + } + mBatteryChartView.setTrapezoidCount(12); + mBatteryChartView.setLevels(levels); + mBatteryChartView.setClickableForce(true); + when(mPowerUsageFeatureProvider.isChartGraphSlotsEnabled(mContext)) + .thenReturn(true); + doReturn(true).when(mMockAccessibilityManager).isEnabled(); + mBatteryChartView.onAttachedToWindow(); + // Ensures the testing environment is correct. + assertThat(mBatteryChartView.isClickable()).isFalse(); + // Turns off accessibility service. + doReturn(false).when(mMockAccessibilityManager).isEnabled(); + + mBatteryChartView.onAttachedToWindow(); + + assertThat(mBatteryChartView.isClickable()).isTrue(); + } + + @Test + public void testOnAttachedToWindow_addAccessibilityStateChangeListener() { + mBatteryChartView.onAttachedToWindow(); + verify(mMockAccessibilityManager) + .addAccessibilityStateChangeListener(mBatteryChartView); + } + + @Test + public void testOnDetachedFromWindow_removeAccessibilityStateChangeListener() { + mBatteryChartView.onAttachedToWindow(); + mBatteryChartView.mHandler.postDelayed( + mBatteryChartView.mUpdateClickableStateRun, 1000); + + mBatteryChartView.onDetachedFromWindow(); + + verify(mMockAccessibilityManager) + .removeAccessibilityStateChangeListener(mBatteryChartView); + assertThat(mBatteryChartView.mHandler.hasCallbacks( + mBatteryChartView.mUpdateClickableStateRun)) + .isFalse(); + } + + @Test + public void testOnAccessibilityStateChanged_postUpdateStateRunnable() { + mBatteryChartView.mHandler = spy(mBatteryChartView.mHandler); + mBatteryChartView.onAccessibilityStateChanged(/*enabled=*/ true); + + verify(mBatteryChartView.mHandler) + .removeCallbacks(mBatteryChartView.mUpdateClickableStateRun); + verify(mBatteryChartView.mHandler) + .postDelayed(mBatteryChartView.mUpdateClickableStateRun, 500L); + } +}