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