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