1 /* 2 * Copyright (C) 2019 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file 5 * except in compliance with the License. You may obtain a copy of the License at 6 * 7 * http://www.apache.org/licenses/LICENSE-2.0 8 * 9 * Unless required by applicable law or agreed to in writing, software distributed under the 10 * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 11 * KIND, either express or implied. See the License for the specific language governing 12 * permissions and limitations under the License. 13 */ 14 15 package com.android.settingslib.graph 16 17 import android.content.Context 18 import android.graphics.BlendMode 19 import android.graphics.Canvas 20 import android.graphics.Color 21 import android.graphics.ColorFilter 22 import android.graphics.Matrix 23 import android.graphics.Paint 24 import android.graphics.Path 25 import android.graphics.PixelFormat 26 import android.graphics.Rect 27 import android.graphics.RectF 28 import android.graphics.drawable.Drawable 29 import android.util.PathParser 30 import android.util.TypedValue 31 32 import com.android.settingslib.R 33 import com.android.settingslib.Utils 34 35 /** 36 * A battery meter drawable that respects paths configured in 37 * frameworks/base/core/res/res/values/config.xml to allow for an easily overrideable battery icon 38 */ 39 open class ThemedBatteryDrawable(private val context: Context, frameColor: Int) : Drawable() { 40 41 // Need to load: 42 // 1. perimeter shape 43 // 2. fill mask (if smaller than perimeter, this would create a fill that 44 // doesn't touch the walls 45 private val perimeterPath = Path() 46 private val scaledPerimeter = Path() 47 private val errorPerimeterPath = Path() 48 private val scaledErrorPerimeter = Path() 49 // Fill will cover the whole bounding rect of the fillMask, and be masked by the path 50 private val fillMask = Path() 51 private val scaledFill = Path() 52 // Based off of the mask, the fill will interpolate across this space 53 private val fillRect = RectF() 54 // Top of this rect changes based on level, 100% == fillRect 55 private val levelRect = RectF() 56 private val levelPath = Path() 57 // Updates the transform of the paths when our bounds change 58 private val scaleMatrix = Matrix() 59 private val padding = Rect() 60 // The net result of fill + perimeter paths 61 private val unifiedPath = Path() 62 63 // Bolt path (used while charging) 64 private val boltPath = Path() 65 private val scaledBolt = Path() 66 67 // Plus sign (used for power save mode) 68 private val plusPath = Path() 69 private val scaledPlus = Path() 70 71 private var intrinsicHeight: Int 72 private var intrinsicWidth: Int 73 74 // To implement hysteresis, keep track of the need to invert the interior icon of the battery 75 private var invertFillIcon = false 76 77 // Colors can be configured based on battery level (see res/values/arrays.xml) 78 private var colorLevels: IntArray 79 80 private var fillColor: Int = Color.MAGENTA 81 private var backgroundColor: Int = Color.MAGENTA 82 // updated whenever level changes 83 private var levelColor: Int = Color.MAGENTA 84 85 // Dual tone implies that battery level is a clipped overlay over top of the whole shape 86 private var dualTone = false 87 88 private var batteryLevel = 0 89 90 private val invalidateRunnable: () -> Unit = { 91 invalidateSelf() 92 } 93 94 open var criticalLevel: Int = context.resources.getInteger( 95 com.android.internal.R.integer.config_criticalBatteryWarningLevel) 96 97 var charging = false 98 set(value) { 99 field = value 100 postInvalidate() 101 } 102 103 var powerSaveEnabled = false 104 set(value) { 105 field = value 106 postInvalidate() 107 } 108 109 private val fillColorStrokePaint = Paint(Paint.ANTI_ALIAS_FLAG).also { p -> 110 p.color = frameColor 111 p.alpha = 255 112 p.isDither = true 113 p.strokeWidth = 5f 114 p.style = Paint.Style.STROKE 115 p.blendMode = BlendMode.SRC 116 p.strokeMiter = 5f 117 p.strokeJoin = Paint.Join.ROUND 118 } 119 120 private val fillColorStrokeProtection = Paint(Paint.ANTI_ALIAS_FLAG).also { p -> 121 p.isDither = true 122 p.strokeWidth = 5f 123 p.style = Paint.Style.STROKE 124 p.blendMode = BlendMode.CLEAR 125 p.strokeMiter = 5f 126 p.strokeJoin = Paint.Join.ROUND 127 } 128 129 private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).also { p -> 130 p.color = frameColor 131 p.alpha = 255 132 p.isDither = true 133 p.strokeWidth = 0f 134 p.style = Paint.Style.FILL_AND_STROKE 135 } 136 137 private val errorPaint = Paint(Paint.ANTI_ALIAS_FLAG).also { p -> 138 p.color = Utils.getColorStateListDefaultColor(context, R.color.batterymeter_plus_color) 139 p.alpha = 255 140 p.isDither = true 141 p.strokeWidth = 0f 142 p.style = Paint.Style.FILL_AND_STROKE 143 p.blendMode = BlendMode.SRC 144 } 145 146 // Only used if dualTone is set to true 147 private val dualToneBackgroundFill = Paint(Paint.ANTI_ALIAS_FLAG).also { p -> 148 p.color = frameColor 149 p.alpha = 85 // ~0.3 alpha by default 150 p.isDither = true 151 p.strokeWidth = 0f 152 p.style = Paint.Style.FILL_AND_STROKE 153 } 154 155 init { 156 val density = context.resources.displayMetrics.density 157 intrinsicHeight = (Companion.HEIGHT * density).toInt() 158 intrinsicWidth = (Companion.WIDTH * density).toInt() 159 160 val res = context.resources 161 val levels = res.obtainTypedArray(R.array.batterymeter_color_levels) 162 val colors = res.obtainTypedArray(R.array.batterymeter_color_values) 163 val N = levels.length() 164 colorLevels = IntArray(2 * N) 165 for (i in 0 until N) { 166 colorLevels[2 * i] = levels.getInt(i, 0) 167 if (colors.getType(i) == TypedValue.TYPE_ATTRIBUTE) { 168 colorLevels[2 * i + 1] = Utils.getColorAttrDefaultColor(context, 169 colors.getThemeAttributeId(i, 0)) 170 } else { 171 colorLevels[2 * i + 1] = colors.getColor(i, 0) 172 } 173 } 174 levels.recycle() 175 colors.recycle() 176 177 loadPaths() 178 } 179 180 override fun draw(c: Canvas) { 181 c.saveLayer(null, null) 182 unifiedPath.reset() 183 levelPath.reset() 184 levelRect.set(fillRect) 185 val fillFraction = batteryLevel / 100f 186 val fillTop = 187 if (batteryLevel >= 95) 188 fillRect.top 189 else 190 fillRect.top + (fillRect.height() * (1 - fillFraction)) 191 192 levelRect.top = Math.floor(fillTop.toDouble()).toFloat() 193 levelPath.addRect(levelRect, Path.Direction.CCW) 194 195 // The perimeter should never change 196 unifiedPath.addPath(scaledPerimeter) 197 // If drawing dual tone, the level is used only to clip the whole drawable path 198 if (!dualTone) { 199 unifiedPath.op(levelPath, Path.Op.UNION) 200 } 201 202 fillPaint.color = levelColor 203 204 // Deal with unifiedPath clipping before it draws 205 if (charging) { 206 // Clip out the bolt shape 207 unifiedPath.op(scaledBolt, Path.Op.DIFFERENCE) 208 if (!invertFillIcon) { 209 c.drawPath(scaledBolt, fillPaint) 210 } 211 } 212 213 if (dualTone) { 214 // Dual tone means we draw the shape again, clipped to the charge level 215 c.drawPath(unifiedPath, dualToneBackgroundFill) 216 c.save() 217 c.clipRect(0f, 218 bounds.bottom - bounds.height() * fillFraction, 219 bounds.right.toFloat(), 220 bounds.bottom.toFloat()) 221 c.drawPath(unifiedPath, fillPaint) 222 c.restore() 223 } else { 224 // Non dual-tone means we draw the perimeter (with the level fill), and potentially 225 // draw the fill again with a critical color 226 fillPaint.color = fillColor 227 c.drawPath(unifiedPath, fillPaint) 228 fillPaint.color = levelColor 229 230 // Show colorError below this level 231 if (batteryLevel <= Companion.CRITICAL_LEVEL && !charging) { 232 c.save() 233 c.clipPath(scaledFill) 234 c.drawPath(levelPath, fillPaint) 235 c.restore() 236 } 237 } 238 239 if (charging) { 240 c.clipOutPath(scaledBolt) 241 if (invertFillIcon) { 242 c.drawPath(scaledBolt, fillColorStrokePaint) 243 } else { 244 c.drawPath(scaledBolt, fillColorStrokeProtection) 245 } 246 } else if (powerSaveEnabled) { 247 // If power save is enabled draw the perimeter path with colorError 248 c.drawPath(scaledErrorPerimeter, errorPaint) 249 // And draw the plus sign on top of the fill 250 c.drawPath(scaledPlus, errorPaint) 251 } 252 c.restore() 253 } 254 255 private fun batteryColorForLevel(level: Int): Int { 256 return when { 257 charging || powerSaveEnabled -> fillColor 258 else -> getColorForLevel(level) 259 } 260 } 261 262 private fun getColorForLevel(level: Int): Int { 263 var thresh: Int 264 var color = 0 265 var i = 0 266 while (i < colorLevels.size) { 267 thresh = colorLevels[i] 268 color = colorLevels[i + 1] 269 if (level <= thresh) { 270 271 // Respect tinting for "normal" level 272 return if (i == colorLevels.size - 2) { 273 fillColor 274 } else { 275 color 276 } 277 } 278 i += 2 279 } 280 return color 281 } 282 283 /** 284 * Alpha is unused internally, and should be defined in the colors passed to {@link setColors}. 285 * Further, setting an alpha for a dual tone battery meter doesn't make sense without bounds 286 * defining the minimum background fill alpha. This is because fill + background must be equal 287 * to the net alpha passed in here. 288 */ 289 override fun setAlpha(alpha: Int) { 290 } 291 292 override fun setColorFilter(colorFilter: ColorFilter?) { 293 fillPaint.colorFilter = colorFilter 294 fillColorStrokePaint.colorFilter = colorFilter 295 dualToneBackgroundFill.colorFilter = colorFilter 296 } 297 298 /** 299 * Deprecated, but required by Drawable 300 */ 301 override fun getOpacity(): Int { 302 return PixelFormat.OPAQUE 303 } 304 305 override fun getIntrinsicHeight(): Int { 306 return intrinsicHeight 307 } 308 309 override fun getIntrinsicWidth(): Int { 310 return intrinsicWidth 311 } 312 313 /** 314 * Set the fill level 315 */ 316 public open fun setBatteryLevel(l: Int) { 317 invertFillIcon = if (l >= 67) true else if (l <= 33) false else invertFillIcon 318 batteryLevel = l 319 levelColor = batteryColorForLevel(batteryLevel) 320 invalidateSelf() 321 } 322 323 public fun getBatteryLevel(): Int { 324 return batteryLevel 325 } 326 327 override fun onBoundsChange(bounds: Rect?) { 328 super.onBoundsChange(bounds) 329 updateSize() 330 } 331 332 fun setPadding(left: Int, top: Int, right: Int, bottom: Int) { 333 padding.left = left 334 padding.top = top 335 padding.right = right 336 padding.bottom = bottom 337 338 updateSize() 339 } 340 341 fun setColors(fgColor: Int, bgColor: Int, singleToneColor: Int) { 342 fillColor = if (dualTone) fgColor else singleToneColor 343 344 fillPaint.color = fillColor 345 fillColorStrokePaint.color = fillColor 346 347 backgroundColor = bgColor 348 dualToneBackgroundFill.color = bgColor 349 350 // Also update the level color, since fillColor may have changed 351 levelColor = batteryColorForLevel(batteryLevel) 352 353 invalidateSelf() 354 } 355 356 private fun postInvalidate() { 357 unscheduleSelf(invalidateRunnable) 358 scheduleSelf(invalidateRunnable, 0) 359 } 360 361 private fun updateSize() { 362 val b = bounds 363 if (b.isEmpty) { 364 scaleMatrix.setScale(1f, 1f) 365 } else { 366 scaleMatrix.setScale((b.right / WIDTH), (b.bottom / HEIGHT)) 367 } 368 369 perimeterPath.transform(scaleMatrix, scaledPerimeter) 370 errorPerimeterPath.transform(scaleMatrix, scaledErrorPerimeter) 371 fillMask.transform(scaleMatrix, scaledFill) 372 scaledFill.computeBounds(fillRect, true) 373 boltPath.transform(scaleMatrix, scaledBolt) 374 plusPath.transform(scaleMatrix, scaledPlus) 375 376 // It is expected that this view only ever scale by the same factor in each dimension, so 377 // just pick one to scale the strokeWidths 378 val scaledStrokeWidth = 379 Math.max(b.right / WIDTH * PROTECTION_STROKE_WIDTH, PROTECTION_MIN_STROKE_WIDTH) 380 381 fillColorStrokePaint.strokeWidth = scaledStrokeWidth 382 fillColorStrokeProtection.strokeWidth = scaledStrokeWidth 383 } 384 385 private fun loadPaths() { 386 val pathString = context.resources.getString( 387 com.android.internal.R.string.config_batterymeterPerimeterPath) 388 perimeterPath.set(PathParser.createPathFromPathData(pathString)) 389 perimeterPath.computeBounds(RectF(), true) 390 391 val errorPathString = context.resources.getString( 392 com.android.internal.R.string.config_batterymeterErrorPerimeterPath) 393 errorPerimeterPath.set(PathParser.createPathFromPathData(errorPathString)) 394 errorPerimeterPath.computeBounds(RectF(), true) 395 396 val fillMaskString = context.resources.getString( 397 com.android.internal.R.string.config_batterymeterFillMask) 398 fillMask.set(PathParser.createPathFromPathData(fillMaskString)) 399 // Set the fill rect so we can calculate the fill properly 400 fillMask.computeBounds(fillRect, true) 401 402 val boltPathString = context.resources.getString( 403 com.android.internal.R.string.config_batterymeterBoltPath) 404 boltPath.set(PathParser.createPathFromPathData(boltPathString)) 405 406 val plusPathString = context.resources.getString( 407 com.android.internal.R.string.config_batterymeterPowersavePath) 408 plusPath.set(PathParser.createPathFromPathData(plusPathString)) 409 410 dualTone = context.resources.getBoolean( 411 com.android.internal.R.bool.config_batterymeterDualTone) 412 } 413 414 companion object { 415 private const val TAG = "ThemedBatteryDrawable" 416 private const val WIDTH = 12f 417 private const val HEIGHT = 20f 418 private const val CRITICAL_LEVEL = 15 419 // On a 12x20 grid, how wide to make the fill protection stroke. 420 // Scales when our size changes 421 private const val PROTECTION_STROKE_WIDTH = 3f 422 // Arbitrarily chosen for visibility at small sizes 423 private const val PROTECTION_MIN_STROKE_WIDTH = 6f 424 } 425 } 426