1 /*
2  * Copyright (C) 2024 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 
17 package com.android.systemui.battery.unified
18 
19 import android.content.Context
20 import android.graphics.Canvas
21 import android.graphics.Matrix
22 import android.graphics.Rect
23 import android.graphics.RectF
24 import android.graphics.Typeface
25 import android.graphics.drawable.Drawable
26 import android.graphics.drawable.LayerDrawable
27 import android.util.PathParser
28 import android.view.Gravity
29 import android.view.View
30 import com.android.systemui.res.R
31 import kotlin.math.ceil
32 import kotlin.math.floor
33 import kotlin.math.roundToInt
34 
35 /**
36  * Custom [Drawable] that manages a list of other drawables which, together, achieve an appropriate
37  * view for [BatteryDrawableState].
38  *
39  * The main elements managed by this drawable are:
40  *
41  *      1. A battery frame background, which may show a solid fill color
42  *      2. The battery frame itself
43  *      3. A custom [BatteryFillDrawable], which renders a fill level, appropriately scale and
44  *         clipped to the battery percent
45  *      4. Percent text
46  *      5. An attribution
47  *
48  * Layers (1) and (2) are loaded directly from xml, as they are static assets. Layer (3) contains a
49  * custom [Drawable.draw] implementation and uses the same path as the battery shape to achieve an
50  * appropriate fill shape.
51  *
52  * The text and attribution layers have the following behaviors:
53  *
54  *      - When text-only or attribute-only, the foreground layer is centered and the maximum size
55  *      - When sharing space between the attribute and the text:
56  *          - The internal space is divided into 12x10 and 6x6 rectangles
57  *          - The attribution is aligned left
58  *          - The percent text is scaled based on the number of characters (1,2, or 3) in the string
59  */
60 @Suppress("RtlHardcoded")
61 class BatteryLayersDrawable(
62     private val frameBg: Drawable,
63     private val frame: Drawable,
64     private val fill: BatteryFillDrawable,
65     private val textOnly: BatteryPercentTextOnlyDrawable,
66     private val spaceSharingText: BatterySpaceSharingPercentTextDrawable,
67     private val attribution: BatteryAttributionDrawable,
68     batteryState: BatteryDrawableState,
69 ) : LayerDrawable(arrayOf(frameBg, frame, fill, textOnly, spaceSharingText, attribution)) {
70 
<lambda>null71     private val scaleMatrix = Matrix().also { it.setScale(1f, 1f) }
72 
73     private val attrFullCanvas = RectF()
74     private val attrRightCanvas = RectF()
75     private val scaledAttrFullCanvas = RectF()
76     private val scaledAttrRightCanvas = RectF()
77 
78     var batteryState = batteryState
79         set(value) {
80             if (field != value) {
81                 // Update before we set the backing field so we can diff
82                 handleUpdateState(field, value)
83                 field = value
84                 invalidateSelf()
85             }
86         }
87 
88     var colors: BatteryColors = BatteryColors.LightThemeColors
89         set(value) {
90             field = value
91             updateColorProfile(batteryState.hasForegroundContent(), batteryState.color, value)
92         }
93 
94     init {
95         isAutoMirrored = true
96         // Initialize the canvas rects since they are not static
97         setAttrRects(layoutDirection == View.LAYOUT_DIRECTION_RTL)
98     }
99 
handleUpdateStatenull100     private fun handleUpdateState(old: BatteryDrawableState, new: BatteryDrawableState) {
101         if (new.level != old.level) {
102             fill.batteryLevel = new.level
103             textOnly.batteryLevel = new.level
104             spaceSharingText.batteryLevel = new.level
105         }
106 
107         val shouldUpdateColors =
108             new.color != old.color ||
109                 new.attribution != attribution.drawable ||
110                 new.hasForegroundContent() != old.hasForegroundContent()
111 
112         if (new.attribution != null && new.attribution != attribution.drawable) {
113             attribution.drawable = new.attribution
114         }
115 
116         if (new.hasForegroundContent() != old.hasForegroundContent()) {
117             setFillInsets(new.hasForegroundContent())
118         }
119 
120         // Finally, update colors last if any of the above conditions were met, so that everything
121         // is properly tinted
122         if (shouldUpdateColors) {
123             updateColorProfile(new.hasForegroundContent(), new.color, colors)
124         }
125     }
126 
updateColorProfilenull127     private fun updateColorProfile(
128         hasFg: Boolean,
129         color: ColorProfile,
130         colorInfo: BatteryColors,
131     ) {
132         frame.setTint(colorInfo.fg)
133         frameBg.setTint(colorInfo.bg)
134         textOnly.setTint(colorInfo.fg)
135         spaceSharingText.setTint(colorInfo.fg)
136         attribution.setTint(colorInfo.fg)
137 
138         when (color) {
139             ColorProfile.None -> {
140                 fill.fillColor = if (hasFg) colorInfo.fill else colorInfo.fillOnly
141             }
142             ColorProfile.Active -> {
143                 fill.fillColor = colorInfo.activeFill
144             }
145             ColorProfile.Warning -> {
146                 fill.fillColor = colorInfo.warnFill
147             }
148             ColorProfile.Error -> {
149                 fill.fillColor = colorInfo.errorFill
150             }
151         }
152     }
153 
setFillInsetsnull154     private fun setFillInsets(
155         hasFg: Boolean,
156     ) {
157         // Extra padding around the fill if there is nothing in the foreground
158         fill.fillInsetAmount = if (hasFg) 0f else 1.5f
159     }
160 
onBoundsChangenull161     override fun onBoundsChange(bounds: Rect) {
162         super.onBoundsChange(bounds)
163 
164         scaleMatrix.setScale(
165             bounds.width() / Metrics.ViewportWidth,
166             bounds.height() / Metrics.ViewportHeight
167         )
168 
169         scaleAttributionBounds()
170     }
171 
onLayoutDirectionChangednull172     override fun onLayoutDirectionChanged(layoutDirection: Int): Boolean {
173         setAttrRects(layoutDirection == View.LAYOUT_DIRECTION_RTL)
174         scaleAttributionBounds()
175 
176         return super.onLayoutDirectionChanged(layoutDirection)
177     }
178 
setAttrRectsnull179     private fun setAttrRects(rtl: Boolean) {
180         // Local refs make the math easier to parse
181         val full = Metrics.AttrFullCanvasInsets
182         val side = Metrics.AttrRightCanvasInsets
183         val sideRtl = Metrics.AttrRightCanvasInsetsRtl
184         val vh = Metrics.ViewportHeight
185         val vw = Metrics.ViewportWidth
186 
187         attrFullCanvas.set(
188             if (rtl) full.right else full.left,
189             full.top,
190             vw - if (rtl) full.left else full.right,
191             vh - full.bottom,
192         )
193         attrRightCanvas.set(
194             if (rtl) sideRtl.left else side.left,
195             side.top,
196             vw - (if (rtl) sideRtl.right else side.right),
197             vh - side.bottom,
198         )
199     }
200 
201     /** If bounds (i.e., scale), or RTL properties change, we have to recalculate the attr bounds */
scaleAttributionBoundsnull202     private fun scaleAttributionBounds() {
203         scaleMatrix.mapRect(scaledAttrFullCanvas, attrFullCanvas)
204         scaleMatrix.mapRect(scaledAttrRightCanvas, attrRightCanvas)
205     }
206 
drawnull207     override fun draw(canvas: Canvas) {
208         // 1. Draw the frame bg
209         frameBg.draw(canvas)
210         // 2. Then the frame itself
211         frame.draw(canvas)
212 
213         // 3. Fill it the appropriate amount
214         fill.draw(canvas)
215 
216         // 4. Decide what goes inside
217         if (batteryState.showPercent && batteryState.attribution != null) {
218             // 4a. percent & attribution. Implies space-sharing
219 
220             // Configure the attribute to draw in a smaller bounding box and align left and use
221             // floor/ceil math to make sure we get every available pixel
222             attribution.gravity = Gravity.LEFT
223             attribution.setBounds(
224                 floor(scaledAttrRightCanvas.left).toInt(),
225                 floor(scaledAttrRightCanvas.top).toInt(),
226                 ceil(scaledAttrRightCanvas.right).toInt(),
227                 ceil(scaledAttrRightCanvas.bottom).toInt(),
228             )
229             attribution.draw(canvas)
230 
231             spaceSharingText.draw(canvas)
232         } else if (batteryState.showPercent) {
233             // 4b. Percent only
234             textOnly.draw(canvas)
235         } else if (batteryState.attribution != null) {
236             // 4c. Attribution only
237             attribution.gravity = Gravity.CENTER
238             attribution.setBounds(
239                 scaledAttrFullCanvas.left.roundToInt(),
240                 scaledAttrFullCanvas.top.roundToInt(),
241                 scaledAttrFullCanvas.right.roundToInt(),
242                 scaledAttrFullCanvas.bottom.roundToInt(),
243             )
244             attribution.draw(canvas)
245         }
246     }
247 
248     /**
249      * This drawable relies on [BatteryColors] to encode all alpha in their values, so we ignore
250      * externally-set alpha
251      */
setAlphanull252     override fun setAlpha(alpha: Int) {}
253 
254     /**
255      * Interface that describes relevant top-level metrics for the proper rendering of this icon.
256      * The overall canvas is defined as ViewportWidth x ViewportHeight, which is hard coded to 24x14
257      * points.
258      *
259      * The attr canvas insets are rect inset definitions. That is, they are defined as l,t,r,b
260      * points from the nearest edge. Note that for RTL, we don't actually flip the text since
261      * numbers do not reverse for RTL locales.
262      */
263     interface M {
264         val ViewportWidth: Float
265         val ViewportHeight: Float
266 
267         /**
268          * Insets, oriented in the above viewport in LTR, that define the full canvas for a single
269          * foreground element. The element will be fit-center and center-aligned on this canvas
270          *
271          * 18x8 point size
272          */
273         val AttrFullCanvasInsets: RectF
274 
275         /**
276          * Insets, oriented in the above viewport in LTR, that define the partial canvas for a
277          * foreground element that shares space with the percent text. The element will be
278          * fit-center and left-aligned on this canvas.
279          *
280          * 6x6 point size
281          */
282         val AttrRightCanvasInsets: RectF
283 
284         /**
285          * Insets, oriented in the above viewport in RTL, that define the partial canvas for a
286          * foreground element that shares space with the percent text. The element will be
287          * fit-center and left-aligned on this canvas.
288          *
289          * 6x6 point size
290          */
291         val AttrRightCanvasInsetsRtl: RectF
292     }
293 
294     companion object {
295         private val PercentFont = Typeface.create("google-sans", Typeface.BOLD)
296 
297         /**
298          * Think of this like the `android:<attr>` values in a drawable.xml file. [Metrics] defines
299          * relevant canvas and size information for us to layout this cluster of drawables
300          */
301         val Metrics =
302             object : M {
303                 override val ViewportWidth: Float = 24f
304                 override val ViewportHeight: Float = 14f
305 
306                 override val AttrFullCanvasInsets = RectF(4f, 3f, 2f, 3f)
307                 override val AttrRightCanvasInsets = RectF(16f, 4f, 2f, 4f)
308                 override val AttrRightCanvasInsetsRtl = RectF(14f, 4f, 4f, 4f)
309             }
310 
311         /**
312          * Create all of the layers needed by [BatteryLayersDrawable]. This class relies on the
313          * following resources to exist in order to properly render:
314          * - R.drawable.battery_unified_frame_bg
315          * - R.drawable.battery_unified_frame
316          * - R.string.battery_unified_frame_path_string
317          * - GoogleSans bold font
318          *
319          * See [BatteryDrawableState] for how to set the properties of the resulting class
320          */
321         @Suppress("UseCompatLoadingForDrawables")
newBatteryDrawablenull322         fun newBatteryDrawable(
323             context: Context,
324             initialState: BatteryDrawableState = BatteryDrawableState.DefaultInitialState,
325         ): BatteryLayersDrawable {
326             val framePath =
327                 PathParser.createPathFromPathData(
328                     context.getString(R.string.battery_unified_frame_path_string)
329                 )
330 
331             val frameBg =
332                 context.getDrawable(R.drawable.battery_unified_frame_bg)
333                     ?: throw IllegalStateException("Missing battery_unified_frame_bg.xml")
334             val frame =
335                 context.getDrawable(R.drawable.battery_unified_frame)
336                     ?: throw IllegalStateException("Missing battery_unified_frame.xml")
337             val fill = BatteryFillDrawable(framePath)
338             val textOnly = BatteryPercentTextOnlyDrawable(PercentFont)
339             val spaceSharingText = BatterySpaceSharingPercentTextDrawable(PercentFont)
340             val attribution = BatteryAttributionDrawable(null)
341 
342             return BatteryLayersDrawable(
343                 frameBg = frameBg,
344                 frame = frame,
345                 fill = fill,
346                 textOnly = textOnly,
347                 spaceSharingText = spaceSharingText,
348                 attribution = attribution,
349                 batteryState = initialState,
350             )
351         }
352     }
353 }
354