1 /*
2  * Copyright (C) 2021 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 package com.android.systemui.battery;
17 
18 import static android.provider.Settings.System.SHOW_BATTERY_PERCENT;
19 
20 import static com.android.systemui.DejankUtils.whitelistIpcs;
21 
22 import static java.lang.annotation.RetentionPolicy.SOURCE;
23 
24 import android.animation.LayoutTransition;
25 import android.animation.ObjectAnimator;
26 import android.annotation.IntDef;
27 import android.annotation.IntRange;
28 import android.content.Context;
29 import android.content.res.Configuration;
30 import android.content.res.Resources;
31 import android.content.res.TypedArray;
32 import android.graphics.Rect;
33 import android.graphics.drawable.Drawable;
34 import android.os.UserHandle;
35 import android.provider.Settings;
36 import android.text.TextUtils;
37 import android.util.AttributeSet;
38 import android.util.TypedValue;
39 import android.view.Gravity;
40 import android.view.LayoutInflater;
41 import android.widget.ImageView;
42 import android.widget.LinearLayout;
43 import android.widget.TextView;
44 
45 import androidx.annotation.StyleRes;
46 import androidx.annotation.VisibleForTesting;
47 
48 import com.android.app.animation.Interpolators;
49 import com.android.systemui.DualToneHandler;
50 import com.android.systemui.R;
51 import com.android.systemui.plugins.DarkIconDispatcher;
52 import com.android.systemui.plugins.DarkIconDispatcher.DarkReceiver;
53 import com.android.systemui.statusbar.policy.BatteryController;
54 
55 import java.io.PrintWriter;
56 import java.lang.annotation.Retention;
57 import java.text.NumberFormat;
58 import java.util.ArrayList;
59 
60 public class BatteryMeterView extends LinearLayout implements DarkReceiver {
61 
62     @Retention(SOURCE)
63     @IntDef({MODE_DEFAULT, MODE_ON, MODE_OFF, MODE_ESTIMATE})
64     public @interface BatteryPercentMode {}
65     public static final int MODE_DEFAULT = 0;
66     public static final int MODE_ON = 1;
67     public static final int MODE_OFF = 2;
68     public static final int MODE_ESTIMATE = 3;
69 
70     private final AccessorizedBatteryDrawable mDrawable;
71     private final ImageView mBatteryIconView;
72     private TextView mBatteryPercentView;
73 
74     private final @StyleRes int mPercentageStyleId;
75     private int mTextColor;
76     private int mLevel;
77     private int mShowPercentMode = MODE_DEFAULT;
78     private boolean mShowPercentAvailable;
79     private String mEstimateText = null;
80     private boolean mPluggedIn;
81     private boolean mIsBatteryDefender;
82     private boolean mIsIncompatibleCharging;
83     private boolean mDisplayShieldEnabled;
84     // Error state where we know nothing about the current battery state
85     private boolean mBatteryStateUnknown;
86     // Lazily-loaded since this is expected to be a rare-if-ever state
87     private Drawable mUnknownStateDrawable;
88 
89     private DualToneHandler mDualToneHandler;
90 
91     private int mNonAdaptedSingleToneColor;
92     private int mNonAdaptedForegroundColor;
93     private int mNonAdaptedBackgroundColor;
94 
95     private BatteryEstimateFetcher mBatteryEstimateFetcher;
96 
BatteryMeterView(Context context, AttributeSet attrs)97     public BatteryMeterView(Context context, AttributeSet attrs) {
98         this(context, attrs, 0);
99     }
100 
BatteryMeterView(Context context, AttributeSet attrs, int defStyle)101     public BatteryMeterView(Context context, AttributeSet attrs, int defStyle) {
102         super(context, attrs, defStyle);
103 
104         setOrientation(LinearLayout.HORIZONTAL);
105         setGravity(Gravity.CENTER_VERTICAL | Gravity.START);
106 
107         TypedArray atts = context.obtainStyledAttributes(attrs, R.styleable.BatteryMeterView,
108                 defStyle, 0);
109         final int frameColor = atts.getColor(R.styleable.BatteryMeterView_frameColor,
110                 context.getColor(R.color.meter_background_color));
111         mPercentageStyleId = atts.getResourceId(R.styleable.BatteryMeterView_textAppearance, 0);
112         mDrawable = new AccessorizedBatteryDrawable(context, frameColor);
113         atts.recycle();
114 
115         mShowPercentAvailable = context.getResources().getBoolean(
116                 com.android.internal.R.bool.config_battery_percentage_setting_available);
117 
118         setupLayoutTransition();
119 
120         mBatteryIconView = new ImageView(context);
121         mBatteryIconView.setImageDrawable(mDrawable);
122         final MarginLayoutParams mlp = new MarginLayoutParams(
123                 getResources().getDimensionPixelSize(R.dimen.status_bar_battery_icon_width),
124                 getResources().getDimensionPixelSize(R.dimen.status_bar_battery_icon_height));
125         mlp.setMargins(0, 0, 0,
126                 getResources().getDimensionPixelOffset(R.dimen.battery_margin_bottom));
127         addView(mBatteryIconView, mlp);
128 
129         updateShowPercent();
130         mDualToneHandler = new DualToneHandler(context);
131         // Init to not dark at all.
132         onDarkChanged(new ArrayList<Rect>(), 0, DarkIconDispatcher.DEFAULT_ICON_TINT);
133 
134         setClipChildren(false);
135         setClipToPadding(false);
136     }
137 
setupLayoutTransition()138     private void setupLayoutTransition() {
139         LayoutTransition transition = new LayoutTransition();
140         transition.setDuration(200);
141 
142         // Animates appearing/disappearing of the battery percentage text using fade-in/fade-out
143         // and disables all other animation types
144         ObjectAnimator appearAnimator = ObjectAnimator.ofFloat(null, "alpha", 0f, 1f);
145         transition.setAnimator(LayoutTransition.APPEARING, appearAnimator);
146         transition.setInterpolator(LayoutTransition.APPEARING, Interpolators.ALPHA_IN);
147 
148         ObjectAnimator disappearAnimator = ObjectAnimator.ofFloat(null, "alpha", 1f, 0f);
149         transition.setInterpolator(LayoutTransition.DISAPPEARING, Interpolators.ALPHA_OUT);
150         transition.setAnimator(LayoutTransition.DISAPPEARING, disappearAnimator);
151 
152         transition.setAnimator(LayoutTransition.CHANGE_APPEARING, null);
153         transition.setAnimator(LayoutTransition.CHANGE_DISAPPEARING, null);
154         transition.setAnimator(LayoutTransition.CHANGING, null);
155 
156         setLayoutTransition(transition);
157     }
158 
setForceShowPercent(boolean show)159     public void setForceShowPercent(boolean show) {
160         setPercentShowMode(show ? MODE_ON : MODE_DEFAULT);
161     }
162 
163     /**
164      * Force a particular mode of showing percent
165      *
166      * 0 - No preference
167      * 1 - Force on
168      * 2 - Force off
169      * 3 - Estimate
170      * @param mode desired mode (none, on, off)
171      */
setPercentShowMode(@atteryPercentMode int mode)172     public void setPercentShowMode(@BatteryPercentMode int mode) {
173         if (mode == mShowPercentMode) return;
174         mShowPercentMode = mode;
175         updateShowPercent();
176         updatePercentText();
177     }
178 
179     @Override
onConfigurationChanged(Configuration newConfig)180     protected void onConfigurationChanged(Configuration newConfig) {
181         super.onConfigurationChanged(newConfig);
182         updatePercentView();
183         mDrawable.notifyDensityChanged();
184     }
185 
setColorsFromContext(Context context)186     public void setColorsFromContext(Context context) {
187         if (context == null) {
188             return;
189         }
190 
191         mDualToneHandler.setColorsFromContext(context);
192     }
193 
194     @Override
hasOverlappingRendering()195     public boolean hasOverlappingRendering() {
196         return false;
197     }
198 
199     /**
200      * Update battery level
201      *
202      * @param level     int between 0 and 100 (representing percentage value)
203      * @param pluggedIn whether the device is plugged in or not
204      */
onBatteryLevelChanged(@ntRangefrom = 0, to = 100) int level, boolean pluggedIn)205     public void onBatteryLevelChanged(@IntRange(from = 0, to = 100) int level, boolean pluggedIn) {
206         mPluggedIn = pluggedIn;
207         mLevel = level;
208         mDrawable.setCharging(isCharging());
209         mDrawable.setBatteryLevel(level);
210         updatePercentText();
211     }
212 
onPowerSaveChanged(boolean isPowerSave)213     void onPowerSaveChanged(boolean isPowerSave) {
214         mDrawable.setPowerSaveEnabled(isPowerSave);
215     }
216 
onIsBatteryDefenderChanged(boolean isBatteryDefender)217     void onIsBatteryDefenderChanged(boolean isBatteryDefender) {
218         boolean valueChanged = mIsBatteryDefender != isBatteryDefender;
219         mIsBatteryDefender = isBatteryDefender;
220         if (valueChanged) {
221             updateContentDescription();
222             // The battery drawable is a different size depending on whether it's currently
223             // overheated or not, so we need to re-scale the view when overheated changes.
224             scaleBatteryMeterViews();
225         }
226     }
227 
onIsIncompatibleChargingChanged(boolean isIncompatibleCharging)228     void onIsIncompatibleChargingChanged(boolean isIncompatibleCharging) {
229         boolean valueChanged = mIsIncompatibleCharging != isIncompatibleCharging;
230         mIsIncompatibleCharging = isIncompatibleCharging;
231         if (valueChanged) {
232             mDrawable.setCharging(isCharging());
233             updateContentDescription();
234         }
235     }
236 
loadPercentView()237     private TextView loadPercentView() {
238         return (TextView) LayoutInflater.from(getContext())
239                 .inflate(R.layout.battery_percentage_view, null);
240     }
241 
242     /**
243      * Updates percent view by removing old one and reinflating if necessary
244      */
updatePercentView()245     public void updatePercentView() {
246         if (mBatteryPercentView != null) {
247             removeView(mBatteryPercentView);
248             mBatteryPercentView = null;
249         }
250         updateShowPercent();
251     }
252 
253     /**
254      * Sets the fetcher that should be used to get the estimated time remaining for the user's
255      * battery.
256      */
setBatteryEstimateFetcher(BatteryEstimateFetcher fetcher)257     void setBatteryEstimateFetcher(BatteryEstimateFetcher fetcher) {
258         mBatteryEstimateFetcher = fetcher;
259     }
260 
setDisplayShieldEnabled(boolean displayShieldEnabled)261     void setDisplayShieldEnabled(boolean displayShieldEnabled) {
262         mDisplayShieldEnabled = displayShieldEnabled;
263     }
264 
updatePercentText()265     void updatePercentText() {
266         if (mBatteryStateUnknown) {
267             return;
268         }
269 
270         if (mBatteryEstimateFetcher == null) {
271             setPercentTextAtCurrentLevel();
272             return;
273         }
274 
275         if (mBatteryPercentView != null) {
276             if (mShowPercentMode == MODE_ESTIMATE && !isCharging()) {
277                 mBatteryEstimateFetcher.fetchBatteryTimeRemainingEstimate(
278                         (String estimate) -> {
279                     if (mBatteryPercentView == null) {
280                         return;
281                     }
282                     if (estimate != null && mShowPercentMode == MODE_ESTIMATE) {
283                         mEstimateText = estimate;
284                         mBatteryPercentView.setText(estimate);
285                         updateContentDescription();
286                     } else {
287                         setPercentTextAtCurrentLevel();
288                     }
289                 });
290             } else {
291                 setPercentTextAtCurrentLevel();
292             }
293         } else {
294             updateContentDescription();
295         }
296     }
297 
setPercentTextAtCurrentLevel()298     private void setPercentTextAtCurrentLevel() {
299         if (mBatteryPercentView != null) {
300             mEstimateText = null;
301             String percentText = NumberFormat.getPercentInstance().format(mLevel / 100f);
302             // Setting text actually triggers a layout pass (because the text view is set to
303             // wrap_content width and TextView always relayouts for this). Avoid needless
304             // relayout if the text didn't actually change.
305             if (!TextUtils.equals(mBatteryPercentView.getText(), percentText)) {
306                 mBatteryPercentView.setText(percentText);
307             }
308         }
309 
310         updateContentDescription();
311     }
312 
updateContentDescription()313     private void updateContentDescription() {
314         Context context = getContext();
315 
316         String contentDescription;
317         if (mBatteryStateUnknown) {
318             contentDescription = context.getString(R.string.accessibility_battery_unknown);
319         } else if (mShowPercentMode == MODE_ESTIMATE && !TextUtils.isEmpty(mEstimateText)) {
320             contentDescription = context.getString(
321                     mIsBatteryDefender
322                             ? R.string.accessibility_battery_level_charging_paused_with_estimate
323                             : R.string.accessibility_battery_level_with_estimate,
324                     mLevel,
325                     mEstimateText);
326         } else if (mIsBatteryDefender) {
327             contentDescription =
328                     context.getString(R.string.accessibility_battery_level_charging_paused, mLevel);
329         } else if (isCharging()) {
330             contentDescription =
331                     context.getString(R.string.accessibility_battery_level_charging, mLevel);
332         } else {
333             contentDescription = context.getString(R.string.accessibility_battery_level, mLevel);
334         }
335 
336         setContentDescription(contentDescription);
337     }
338 
updateShowPercent()339     void updateShowPercent() {
340         final boolean showing = mBatteryPercentView != null;
341         // TODO(b/140051051)
342         final boolean systemSetting = 0 != whitelistIpcs(() -> Settings.System
343                 .getIntForUser(getContext().getContentResolver(),
344                 SHOW_BATTERY_PERCENT, 0, UserHandle.USER_CURRENT));
345         boolean shouldShow =
346                 (mShowPercentAvailable && systemSetting && mShowPercentMode != MODE_OFF)
347                 || mShowPercentMode == MODE_ON
348                 || mShowPercentMode == MODE_ESTIMATE;
349         shouldShow = shouldShow && !mBatteryStateUnknown;
350 
351         if (shouldShow) {
352             if (!showing) {
353                 mBatteryPercentView = loadPercentView();
354                 if (mPercentageStyleId != 0) { // Only set if specified as attribute
355                     mBatteryPercentView.setTextAppearance(mPercentageStyleId);
356                 }
357                 float fontHeight = mBatteryPercentView.getPaint().getFontMetricsInt(null);
358                 mBatteryPercentView.setLineHeight(TypedValue.COMPLEX_UNIT_PX, fontHeight);
359                 if (mTextColor != 0) mBatteryPercentView.setTextColor(mTextColor);
360                 updatePercentText();
361                 addView(mBatteryPercentView, new LayoutParams(
362                         LayoutParams.WRAP_CONTENT,
363                         (int) Math.ceil(fontHeight)));
364             }
365         } else {
366             if (showing) {
367                 removeView(mBatteryPercentView);
368                 mBatteryPercentView = null;
369             }
370         }
371     }
372 
getUnknownStateDrawable()373     private Drawable getUnknownStateDrawable() {
374         if (mUnknownStateDrawable == null) {
375             mUnknownStateDrawable = mContext.getDrawable(R.drawable.ic_battery_unknown);
376             mUnknownStateDrawable.setTint(mTextColor);
377         }
378 
379         return mUnknownStateDrawable;
380     }
381 
onBatteryUnknownStateChanged(boolean isUnknown)382     void onBatteryUnknownStateChanged(boolean isUnknown) {
383         if (mBatteryStateUnknown == isUnknown) {
384             return;
385         }
386 
387         mBatteryStateUnknown = isUnknown;
388         updateContentDescription();
389 
390         if (mBatteryStateUnknown) {
391             mBatteryIconView.setImageDrawable(getUnknownStateDrawable());
392         } else {
393             mBatteryIconView.setImageDrawable(mDrawable);
394         }
395 
396         updateShowPercent();
397     }
398 
399     /**
400      * Looks up the scale factor for status bar icons and scales the battery view by that amount.
401      */
scaleBatteryMeterViews()402     void scaleBatteryMeterViews() {
403         Resources res = getContext().getResources();
404         TypedValue typedValue = new TypedValue();
405 
406         res.getValue(R.dimen.status_bar_icon_scale_factor, typedValue, true);
407         float iconScaleFactor = typedValue.getFloat();
408 
409         float mainBatteryHeight =
410                 res.getDimensionPixelSize(R.dimen.status_bar_battery_icon_height) * iconScaleFactor;
411         float mainBatteryWidth =
412                 res.getDimensionPixelSize(R.dimen.status_bar_battery_icon_width) * iconScaleFactor;
413 
414         boolean displayShield = mDisplayShieldEnabled && mIsBatteryDefender;
415         float fullBatteryIconHeight =
416                 BatterySpecs.getFullBatteryHeight(mainBatteryHeight, displayShield);
417         float fullBatteryIconWidth =
418                 BatterySpecs.getFullBatteryWidth(mainBatteryWidth, displayShield);
419 
420         int marginTop;
421         if (displayShield) {
422             // If the shield is displayed, we need some extra marginTop so that the bottom of the
423             // main icon is still aligned with the bottom of all the other system icons.
424             int shieldHeightAddition = Math.round(fullBatteryIconHeight - mainBatteryHeight);
425             // However, the other system icons have some embedded bottom padding that the battery
426             // doesn't have, so we shouldn't move the battery icon down by the full amount.
427             // See b/258672854.
428             marginTop = shieldHeightAddition
429                     - res.getDimensionPixelSize(R.dimen.status_bar_battery_extra_vertical_spacing);
430         } else {
431             marginTop = 0;
432         }
433 
434         int marginBottom = res.getDimensionPixelSize(R.dimen.battery_margin_bottom);
435 
436         LinearLayout.LayoutParams scaledLayoutParams = new LinearLayout.LayoutParams(
437                 Math.round(fullBatteryIconWidth),
438                 Math.round(fullBatteryIconHeight));
439         scaledLayoutParams.setMargins(0, marginTop, 0, marginBottom);
440 
441         mDrawable.setDisplayShield(displayShield);
442         mBatteryIconView.setLayoutParams(scaledLayoutParams);
443         mBatteryIconView.invalidateDrawable(mDrawable);
444     }
445 
446     @Override
onDarkChanged(ArrayList<Rect> areas, float darkIntensity, int tint)447     public void onDarkChanged(ArrayList<Rect> areas, float darkIntensity, int tint) {
448         float intensity = DarkIconDispatcher.isInAreas(areas, this) ? darkIntensity : 0;
449         mNonAdaptedSingleToneColor = mDualToneHandler.getSingleColor(intensity);
450         mNonAdaptedForegroundColor = mDualToneHandler.getFillColor(intensity);
451         mNonAdaptedBackgroundColor = mDualToneHandler.getBackgroundColor(intensity);
452 
453         updateColors(mNonAdaptedForegroundColor, mNonAdaptedBackgroundColor,
454                 mNonAdaptedSingleToneColor);
455     }
456 
457     /**
458      * Sets icon and text colors. This will be overridden by {@code onDarkChanged} events,
459      * if registered.
460      *
461      * @param foregroundColor
462      * @param backgroundColor
463      * @param singleToneColor
464      */
updateColors(int foregroundColor, int backgroundColor, int singleToneColor)465     public void updateColors(int foregroundColor, int backgroundColor, int singleToneColor) {
466         mDrawable.setColors(foregroundColor, backgroundColor, singleToneColor);
467         mTextColor = singleToneColor;
468         if (mBatteryPercentView != null) {
469             mBatteryPercentView.setTextColor(singleToneColor);
470         }
471 
472         if (mUnknownStateDrawable != null) {
473             mUnknownStateDrawable.setTint(singleToneColor);
474         }
475     }
476 
isCharging()477     private boolean isCharging() {
478         return mPluggedIn && !mIsIncompatibleCharging;
479     }
480 
dump(PrintWriter pw, String[] args)481     public void dump(PrintWriter pw, String[] args) {
482         String powerSave = mDrawable == null ? null : mDrawable.getPowerSaveEnabled() + "";
483         String displayShield = mDrawable == null ? null : mDrawable.getDisplayShield() + "";
484         String charging = mDrawable == null ? null : mDrawable.getCharging() + "";
485         CharSequence percent = mBatteryPercentView == null ? null : mBatteryPercentView.getText();
486         pw.println("  BatteryMeterView:");
487         pw.println("    mDrawable.getPowerSave: " + powerSave);
488         pw.println("    mDrawable.getDisplayShield: " + displayShield);
489         pw.println("    mDrawable.getCharging: " + charging);
490         pw.println("    mBatteryPercentView.getText(): " + percent);
491         pw.println("    mTextColor: #" + Integer.toHexString(mTextColor));
492         pw.println("    mBatteryStateUnknown: " + mBatteryStateUnknown);
493         pw.println("    mIsIncompatibleCharging: " + mIsIncompatibleCharging);
494         pw.println("    mPluggedIn: " + mPluggedIn);
495         pw.println("    mLevel: " + mLevel);
496         pw.println("    mMode: " + mShowPercentMode);
497     }
498 
499     @VisibleForTesting
getBatteryPercentViewText()500     CharSequence getBatteryPercentViewText() {
501         return mBatteryPercentView.getText();
502     }
503 
504     /** An interface that will fetch the estimated time remaining for the user's battery. */
505     public interface BatteryEstimateFetcher {
fetchBatteryTimeRemainingEstimate( BatteryController.EstimateFetchCompletion completion)506         void fetchBatteryTimeRemainingEstimate(
507                 BatteryController.EstimateFetchCompletion completion);
508     }
509 }
510 
511