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