1 /*
2  * Copyright (C) 2022 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.settings.fuelgauge.batteryusage;
18 
19 import android.app.settings.SettingsEnums;
20 import android.content.Context;
21 import android.content.res.Configuration;
22 import android.graphics.drawable.Drawable;
23 import android.os.Handler;
24 import android.os.Looper;
25 import android.text.TextUtils;
26 import android.util.ArrayMap;
27 import android.util.ArraySet;
28 import android.util.Log;
29 import android.view.View;
30 import android.widget.AdapterView;
31 
32 import androidx.preference.Preference;
33 import androidx.preference.PreferenceCategory;
34 import androidx.preference.PreferenceGroup;
35 import androidx.preference.PreferenceScreen;
36 
37 import com.android.internal.annotations.VisibleForTesting;
38 import com.android.settings.R;
39 import com.android.settings.SettingsActivity;
40 import com.android.settings.Utils;
41 import com.android.settings.core.BasePreferenceController;
42 import com.android.settings.core.InstrumentedPreferenceFragment;
43 import com.android.settings.fuelgauge.AdvancedPowerUsageDetail;
44 import com.android.settings.fuelgauge.BatteryUtils;
45 import com.android.settings.overlay.FeatureFactory;
46 import com.android.settingslib.core.instrumentation.MetricsFeatureProvider;
47 import com.android.settingslib.core.lifecycle.Lifecycle;
48 import com.android.settingslib.core.lifecycle.LifecycleObserver;
49 import com.android.settingslib.core.lifecycle.events.OnDestroy;
50 import com.android.settingslib.core.lifecycle.events.OnResume;
51 import com.android.settingslib.widget.FooterPreference;
52 
53 import java.util.ArrayList;
54 import java.util.List;
55 import java.util.Map;
56 import java.util.Optional;
57 import java.util.Set;
58 
59 /** Controller for battery usage breakdown preference group. */
60 public class BatteryUsageBreakdownController extends BasePreferenceController
61         implements LifecycleObserver, OnResume, OnDestroy {
62     private static final String TAG = "BatteryUsageBreakdownController";
63     private static final String ROOT_PREFERENCE_KEY = "battery_usage_breakdown";
64     private static final String FOOTER_PREFERENCE_KEY = "battery_usage_footer";
65     private static final String SPINNER_PREFERENCE_KEY = "battery_usage_spinner";
66     private static final String APP_LIST_PREFERENCE_KEY = "app_list";
67     private static final String PACKAGE_NAME_NONE = "none";
68     private static final String SLOT_TIMESTAMP = "slot_timestamp";
69     private static final String ANOMALY_KEY = "anomaly_key";
70     private static final List<BatteryDiffEntry> EMPTY_ENTRY_LIST = new ArrayList<>();
71 
72     private static int sUiMode = Configuration.UI_MODE_NIGHT_UNDEFINED;
73 
74     private final SettingsActivity mActivity;
75     private final InstrumentedPreferenceFragment mFragment;
76     private final MetricsFeatureProvider mMetricsFeatureProvider;
77     private final Handler mHandler = new Handler(Looper.getMainLooper());
78 
79     @VisibleForTesting final Map<String, Preference> mPreferenceCache = new ArrayMap<>();
80 
81     private int mSpinnerPosition;
82     private String mSlotInformation;
83 
84     @VisibleForTesting Context mPrefContext;
85     @VisibleForTesting PreferenceCategory mRootPreference;
86     @VisibleForTesting SpinnerPreference mSpinnerPreference;
87     @VisibleForTesting PreferenceGroup mAppListPreferenceGroup;
88     @VisibleForTesting FooterPreference mFooterPreference;
89     @VisibleForTesting BatteryDiffData mBatteryDiffData;
90     @VisibleForTesting String mBatteryUsageBreakdownTitleLastFullChargeText;
91     @VisibleForTesting String mPercentLessThanThresholdText;
92     @VisibleForTesting String mPercentLessThanThresholdContentDescription;
93     @VisibleForTesting boolean mIsHighlightSlot;
94     @VisibleForTesting int mAnomalyKeyNumber;
95     @VisibleForTesting String mAnomalyEntryKey;
96     @VisibleForTesting String mAnomalyHintString;
97     @VisibleForTesting String mAnomalyHintPrefKey;
98 
BatteryUsageBreakdownController( Context context, Lifecycle lifecycle, SettingsActivity activity, InstrumentedPreferenceFragment fragment)99     public BatteryUsageBreakdownController(
100             Context context,
101             Lifecycle lifecycle,
102             SettingsActivity activity,
103             InstrumentedPreferenceFragment fragment) {
104         super(context, ROOT_PREFERENCE_KEY);
105         mActivity = activity;
106         mFragment = fragment;
107         mMetricsFeatureProvider = FeatureFactory.getFeatureFactory().getMetricsFeatureProvider();
108         if (lifecycle != null) {
109             lifecycle.addObserver(this);
110         }
111     }
112 
113     @Override
onResume()114     public void onResume() {
115         final int currentUiMode =
116                 mContext.getResources().getConfiguration().uiMode
117                         & Configuration.UI_MODE_NIGHT_MASK;
118         if (sUiMode != currentUiMode) {
119             sUiMode = currentUiMode;
120             BatteryDiffEntry.clearCache();
121             mPreferenceCache.clear();
122             Log.d(TAG, "clear icon and label cache since uiMode is changed");
123         }
124     }
125 
126     @Override
onDestroy()127     public void onDestroy() {
128         mHandler.removeCallbacksAndMessages(/* token= */ null);
129         mPreferenceCache.clear();
130         mAppListPreferenceGroup.removeAll();
131     }
132 
133     @Override
getAvailabilityStatus()134     public int getAvailabilityStatus() {
135         return AVAILABLE;
136     }
137 
138     @Override
isSliceable()139     public boolean isSliceable() {
140         return false;
141     }
142 
isAnomalyBatteryDiffEntry(BatteryDiffEntry entry)143     private boolean isAnomalyBatteryDiffEntry(BatteryDiffEntry entry) {
144         return mIsHighlightSlot
145                 && mAnomalyEntryKey != null
146                 && mAnomalyEntryKey.equals(entry.getKey());
147     }
148 
logPreferenceClickedMetrics(BatteryDiffEntry entry)149     private void logPreferenceClickedMetrics(BatteryDiffEntry entry) {
150         final int attribution = SettingsEnums.OPEN_BATTERY_USAGE;
151         final int action =
152                 entry.isSystemEntry()
153                         ? SettingsEnums.ACTION_BATTERY_USAGE_SYSTEM_ITEM
154                         : SettingsEnums.ACTION_BATTERY_USAGE_APP_ITEM;
155         final int pageId = SettingsEnums.OPEN_BATTERY_USAGE;
156         final String packageName =
157                 TextUtils.isEmpty(entry.getPackageName())
158                         ? PACKAGE_NAME_NONE
159                         : entry.getPackageName();
160         final int percentage = (int) Math.round(entry.getPercentage());
161         final int slotTimestamp = (int) (mBatteryDiffData.getStartTimestamp() / 1000);
162         mMetricsFeatureProvider.action(attribution, action, pageId, packageName, percentage);
163         mMetricsFeatureProvider.action(attribution, action, pageId, SLOT_TIMESTAMP, slotTimestamp);
164 
165         if (isAnomalyBatteryDiffEntry(entry)) {
166             mMetricsFeatureProvider.action(
167                     attribution, action, pageId, ANOMALY_KEY, mAnomalyKeyNumber);
168         }
169     }
170 
171     @Override
handlePreferenceTreeClick(Preference preference)172     public boolean handlePreferenceTreeClick(Preference preference) {
173         if (!(preference instanceof PowerGaugePreference)) {
174             return false;
175         }
176         final PowerGaugePreference powerPref = (PowerGaugePreference) preference;
177         final BatteryDiffEntry diffEntry = powerPref.getBatteryDiffEntry();
178         logPreferenceClickedMetrics(diffEntry);
179         Log.d(
180                 TAG,
181                 String.format(
182                         "handleClick() label=%s key=%s package=%s",
183                         diffEntry.getAppLabel(), diffEntry.getKey(), diffEntry.getPackageName()));
184         final String anomalyHintPrefKey =
185                 isAnomalyBatteryDiffEntry(diffEntry) ? mAnomalyHintPrefKey : null;
186         final String anomalyHintText =
187                 isAnomalyBatteryDiffEntry(diffEntry) ? mAnomalyHintString : null;
188         AdvancedPowerUsageDetail.startBatteryDetailPage(
189                 mActivity,
190                 mFragment.getMetricsCategory(),
191                 diffEntry,
192                 powerPref.getPercentage(),
193                 mSlotInformation,
194                 /* showTimeInformation= */ true,
195                 anomalyHintPrefKey,
196                 anomalyHintText);
197         return true;
198     }
199 
200     @Override
displayPreference(PreferenceScreen screen)201     public void displayPreference(PreferenceScreen screen) {
202         super.displayPreference(screen);
203         mPrefContext = screen.getContext();
204         mRootPreference = screen.findPreference(ROOT_PREFERENCE_KEY);
205         mSpinnerPreference = screen.findPreference(SPINNER_PREFERENCE_KEY);
206         mAppListPreferenceGroup = screen.findPreference(APP_LIST_PREFERENCE_KEY);
207         mFooterPreference = screen.findPreference(FOOTER_PREFERENCE_KEY);
208         mBatteryUsageBreakdownTitleLastFullChargeText =
209                 mPrefContext.getString(
210                         R.string.battery_usage_breakdown_title_since_last_full_charge);
211         final String formatPercentage =
212                 Utils.formatPercentage(BatteryDiffData.SMALL_PERCENTAGE_THRESHOLD, false);
213         mPercentLessThanThresholdText =
214                 mPrefContext.getString(R.string.battery_usage_less_than_percent, formatPercentage);
215         mPercentLessThanThresholdContentDescription =
216                 mPrefContext.getString(
217                         R.string.battery_usage_less_than_percent_content_description,
218                         formatPercentage);
219 
220         mAppListPreferenceGroup.setOrderingAsAdded(false);
221         mSpinnerPreference.initializeSpinner(
222                 new String[] {
223                     mPrefContext.getString(R.string.battery_usage_spinner_view_by_apps),
224                     mPrefContext.getString(R.string.battery_usage_spinner_view_by_systems)
225                 },
226                 new AdapterView.OnItemSelectedListener() {
227                     @Override
228                     public void onItemSelected(
229                             AdapterView<?> parent, View view, int position, long id) {
230                         if (mSpinnerPosition != position) {
231                             mSpinnerPosition = position;
232                             mHandler.post(
233                                     () -> {
234                                         removeAndCacheAllUnusedPreferences();
235                                         addAllPreferences();
236                                         mMetricsFeatureProvider.action(
237                                                 mPrefContext,
238                                                 SettingsEnums.ACTION_BATTERY_USAGE_SPINNER,
239                                                 mSpinnerPosition);
240                                     });
241                         }
242                     }
243 
244                     @Override
245                     public void onNothingSelected(AdapterView<?> parent) {}
246                 });
247     }
248 
249     /**
250      * Updates UI when the battery usage is updated.
251      *
252      * @param slotUsageData The battery usage diff data for the selected slot. This is used in the
253      *     app list.
254      * @param slotTimestamp The selected slot timestamp information. This is used in the battery
255      *     usage breakdown category.
256      * @param isAllUsageDataEmpty Whether all the battery usage data is null or empty. This is used
257      *     when showing the footer.
258      */
handleBatteryUsageUpdated( BatteryDiffData slotUsageData, String slotTimestamp, String accessibilitySlotTimestamp, boolean isAllUsageDataEmpty, boolean isHighlightSlot, Optional<AnomalyEventWrapper> optionalAnomalyEventWrapper)259     void handleBatteryUsageUpdated(
260             BatteryDiffData slotUsageData,
261             String slotTimestamp,
262             String accessibilitySlotTimestamp,
263             boolean isAllUsageDataEmpty,
264             boolean isHighlightSlot,
265             Optional<AnomalyEventWrapper> optionalAnomalyEventWrapper) {
266         mBatteryDiffData = slotUsageData;
267         mSlotInformation = slotTimestamp;
268         mIsHighlightSlot = isHighlightSlot;
269 
270         if (optionalAnomalyEventWrapper != null) {
271             final AnomalyEventWrapper anomalyEventWrapper =
272                     optionalAnomalyEventWrapper.orElse(null);
273             mAnomalyKeyNumber =
274                     anomalyEventWrapper != null ? anomalyEventWrapper.getAnomalyKeyNumber() : -1;
275             mAnomalyEntryKey =
276                     anomalyEventWrapper != null ? anomalyEventWrapper.getAnomalyEntryKey() : null;
277             mAnomalyHintString =
278                     anomalyEventWrapper != null ? anomalyEventWrapper.getAnomalyHintString() : null;
279             mAnomalyHintPrefKey =
280                     anomalyEventWrapper != null
281                             ? anomalyEventWrapper.getAnomalyHintPrefKey()
282                             : null;
283         }
284 
285         showCategoryTitle(slotTimestamp, accessibilitySlotTimestamp);
286         showSpinnerAndAppList();
287         showFooterPreference(isAllUsageDataEmpty);
288     }
289 
showCategoryTitle(String slotTimestamp, String accessibilitySlotTimestamp)290     private void showCategoryTitle(String slotTimestamp, String accessibilitySlotTimestamp) {
291         final String displayTitle =
292                 slotTimestamp == null
293                         ? mBatteryUsageBreakdownTitleLastFullChargeText
294                         : mPrefContext.getString(
295                                 R.string.battery_usage_breakdown_title_for_slot, slotTimestamp);
296         final String accessibilityTitle =
297                 accessibilitySlotTimestamp == null
298                         ? mBatteryUsageBreakdownTitleLastFullChargeText
299                         : mPrefContext.getString(
300                                 R.string.battery_usage_breakdown_title_for_slot,
301                                 accessibilitySlotTimestamp);
302         mRootPreference.setTitle(Utils.createAccessibleSequence(displayTitle, accessibilityTitle));
303         mRootPreference.setVisible(true);
304     }
305 
showFooterPreference(boolean isAllBatteryUsageEmpty)306     private void showFooterPreference(boolean isAllBatteryUsageEmpty) {
307         mFooterPreference.setTitle(
308                 mPrefContext.getString(
309                         isAllBatteryUsageEmpty
310                                 ? R.string.battery_usage_screen_footer_empty
311                                 : R.string.battery_usage_screen_footer));
312         mFooterPreference.setVisible(true);
313     }
314 
showSpinnerAndAppList()315     private void showSpinnerAndAppList() {
316         if (mBatteryDiffData == null) {
317             mHandler.post(
318                     () -> {
319                         removeAndCacheAllUnusedPreferences();
320                     });
321             return;
322         }
323         mSpinnerPreference.setVisible(true);
324         mAppListPreferenceGroup.setVisible(true);
325         mHandler.post(
326                 () -> {
327                     removeAndCacheAllUnusedPreferences();
328                     addAllPreferences();
329                 });
330     }
331 
getBatteryDiffEntries()332     private List<BatteryDiffEntry> getBatteryDiffEntries() {
333         if (mBatteryDiffData == null) {
334             return EMPTY_ENTRY_LIST;
335         }
336         return mSpinnerPosition == 0
337                 ? mBatteryDiffData.getAppDiffEntryList()
338                 : mBatteryDiffData.getSystemDiffEntryList();
339     }
340 
341     @VisibleForTesting
addAllPreferences()342     void addAllPreferences() {
343         if (mBatteryDiffData == null) {
344             return;
345         }
346         final long start = System.currentTimeMillis();
347         final List<BatteryDiffEntry> entries = getBatteryDiffEntries();
348         int prefIndex = mAppListPreferenceGroup.getPreferenceCount();
349         for (BatteryDiffEntry entry : entries) {
350             boolean isAdded = false;
351             final String appLabel = entry.getAppLabel();
352             final Drawable appIcon = entry.getAppIcon();
353             if (TextUtils.isEmpty(appLabel) || appIcon == null) {
354                 Log.w(TAG, "cannot find app resource for:" + entry.getPackageName());
355                 continue;
356             }
357             final String prefKey = entry.getKey();
358             AnomalyAppItemPreference pref = mAppListPreferenceGroup.findPreference(prefKey);
359             if (pref != null) {
360                 isAdded = true;
361             } else {
362                 pref = (AnomalyAppItemPreference) mPreferenceCache.get(prefKey);
363             }
364             // Creates new instance if cached preference is not found.
365             if (pref == null) {
366                 pref = new AnomalyAppItemPreference(mPrefContext);
367                 pref.setKey(prefKey);
368                 mPreferenceCache.put(prefKey, pref);
369             }
370             pref.setIcon(appIcon);
371             pref.setTitle(appLabel);
372             pref.setOrder(prefIndex);
373             pref.setSingleLineTitle(true);
374             // Updates App item preference style
375             pref.setAnomalyHint(isAnomalyBatteryDiffEntry(entry) ? mAnomalyHintString : null);
376             // Sets the BatteryDiffEntry to preference for launching detailed page.
377             pref.setBatteryDiffEntry(entry);
378             pref.setSelectable(entry.validForRestriction());
379             setPreferencePercentage(pref, entry);
380             setPreferenceSummary(pref, entry);
381             if (!isAdded) {
382                 mAppListPreferenceGroup.addPreference(pref);
383             }
384             prefIndex++;
385         }
386         Log.d(
387                 TAG,
388                 String.format(
389                         "addAllPreferences() is finished in %d/ms",
390                         (System.currentTimeMillis() - start)));
391     }
392 
393     @VisibleForTesting
removeAndCacheAllUnusedPreferences()394     void removeAndCacheAllUnusedPreferences() {
395         List<BatteryDiffEntry> entries = getBatteryDiffEntries();
396         Set<String> entryKeySet = new ArraySet<>(entries.size());
397         entries.forEach(entry -> entryKeySet.add(entry.getKey()));
398         final int prefsCount = mAppListPreferenceGroup.getPreferenceCount();
399         for (int index = prefsCount - 1; index >= 0; index--) {
400             final Preference pref = mAppListPreferenceGroup.getPreference(index);
401             if (entryKeySet.contains(pref.getKey())) {
402                 // The pref is still used, don't remove.
403                 continue;
404             }
405             if (!TextUtils.isEmpty(pref.getKey())) {
406                 mPreferenceCache.put(pref.getKey(), pref);
407             }
408             mAppListPreferenceGroup.removePreference(pref);
409         }
410     }
411 
412     @VisibleForTesting
setPreferencePercentage(PowerGaugePreference preference, BatteryDiffEntry entry)413     void setPreferencePercentage(PowerGaugePreference preference, BatteryDiffEntry entry) {
414         if (entry.getPercentage() < BatteryDiffData.SMALL_PERCENTAGE_THRESHOLD) {
415             preference.setPercentage(mPercentLessThanThresholdText);
416             preference.setPercentageContentDescription(mPercentLessThanThresholdContentDescription);
417         } else {
418             preference.setPercentage(
419                     Utils.formatPercentage(
420                             entry.getPercentage() + entry.getAdjustPercentageOffset(),
421                             /* round= */ true));
422         }
423     }
424 
425     @VisibleForTesting
setPreferenceSummary(PowerGaugePreference preference, BatteryDiffEntry entry)426     void setPreferenceSummary(PowerGaugePreference preference, BatteryDiffEntry entry) {
427         preference.setSummary(
428                 BatteryUtils.buildBatteryUsageTimeSummary(
429                         mPrefContext,
430                         entry.isSystemEntry(),
431                         entry.mForegroundUsageTimeInMs,
432                         entry.mBackgroundUsageTimeInMs + entry.mForegroundServiceUsageTimeInMs,
433                         entry.mScreenOnTimeInMs));
434     }
435 }
436