1 /*
2  * Copyright (C) 2022 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.settings.fuelgauge.batteryusage;
17 
18 import static com.android.settings.Utils.formatPercentage;
19 import static com.android.settings.fuelgauge.batteryusage.BatteryChartViewModel.AxisLabelPosition.BETWEEN_TRAPEZOIDS;
20 import static com.android.settingslib.fuelgauge.BatteryStatus.BATTERY_LEVEL_UNKNOWN;
21 
22 import static java.lang.Math.abs;
23 import static java.lang.Math.round;
24 import static java.util.Objects.requireNonNull;
25 
26 import android.content.Context;
27 import android.content.res.Resources;
28 import android.graphics.Canvas;
29 import android.graphics.Color;
30 import android.graphics.CornerPathEffect;
31 import android.graphics.Paint;
32 import android.graphics.Path;
33 import android.graphics.Rect;
34 import android.graphics.drawable.Drawable;
35 import android.os.Bundle;
36 import android.util.ArraySet;
37 import android.util.AttributeSet;
38 import android.util.Log;
39 import android.view.HapticFeedbackConstants;
40 import android.view.MotionEvent;
41 import android.view.View;
42 import android.view.ViewParent;
43 import android.view.accessibility.AccessibilityEvent;
44 import android.view.accessibility.AccessibilityManager;
45 import android.view.accessibility.AccessibilityNodeInfo;
46 import android.view.accessibility.AccessibilityNodeProvider;
47 import android.widget.TextView;
48 
49 import androidx.annotation.NonNull;
50 import androidx.annotation.Nullable;
51 import androidx.annotation.VisibleForTesting;
52 import androidx.appcompat.widget.AppCompatImageView;
53 
54 import com.android.settings.R;
55 import com.android.settingslib.Utils;
56 
57 import java.util.ArrayList;
58 import java.util.List;
59 import java.util.Locale;
60 import java.util.Set;
61 
62 /** A widget component to draw chart graph. */
63 public class BatteryChartView extends AppCompatImageView implements View.OnClickListener {
64     private static final String TAG = "BatteryChartView";
65 
66     private static final int DIVIDER_COLOR = Color.parseColor("#CDCCC5");
67     private static final int HORIZONTAL_DIVIDER_COUNT = 5;
68 
69     /** A callback listener for selected group index is updated. */
70     public interface OnSelectListener {
71         /** The callback function for selected group index is updated. */
onSelect(int trapezoidIndex)72         void onSelect(int trapezoidIndex);
73     }
74 
75     private final String[] mPercentages = getPercentages();
76     private final Rect mIndent = new Rect();
77     private final Rect[] mPercentageBounds = new Rect[] {new Rect(), new Rect(), new Rect()};
78     private final List<Rect> mAxisLabelsBounds = new ArrayList<>();
79     private final Set<Integer> mLabelDrawnIndexes = new ArraySet<>();
80     private final int mLayoutDirection =
81             getContext().getResources().getConfiguration().getLayoutDirection();
82 
83     private BatteryChartViewModel mViewModel;
84     private int mHoveredIndex = BatteryChartViewModel.SELECTED_INDEX_INVALID;
85     private int mDividerWidth;
86     private int mDividerHeight;
87     private float mTrapezoidVOffset;
88     private float mTrapezoidHOffset;
89     private int mTrapezoidColor;
90     private int mTrapezoidSolidColor;
91     private int mTrapezoidHoverColor;
92     private int mDefaultTextColor;
93     private int mTextPadding;
94     private int mTransomIconSize;
95     private int mTransomTop;
96     private int mTransomViewHeight;
97     private int mTransomLineDefaultColor;
98     private int mTransomLineSelectedColor;
99     private float mTransomPadding;
100     private Drawable mTransomIcon;
101     private Paint mTransomLinePaint;
102     private Paint mTransomSelectedSlotPaint;
103     private Paint mDividerPaint;
104     private Paint mTrapezoidPaint;
105     private Paint mTextPaint;
106     private AccessibilityNodeProvider mAccessibilityNodeProvider;
107     private BatteryChartView.OnSelectListener mOnSelectListener;
108 
109     @VisibleForTesting TrapezoidSlot[] mTrapezoidSlots;
110     // Records the location to calculate selected index.
111     @VisibleForTesting float mTouchUpEventX = Float.MIN_VALUE;
112 
BatteryChartView(Context context)113     public BatteryChartView(Context context) {
114         super(context, null);
115     }
116 
BatteryChartView(Context context, AttributeSet attrs)117     public BatteryChartView(Context context, AttributeSet attrs) {
118         super(context, attrs);
119         initializeColors(context);
120         // Registers the click event listener.
121         setOnClickListener(this);
122         setClickable(false);
123         requestLayout();
124     }
125 
126     /** Sets the data model of this view. */
setViewModel(BatteryChartViewModel viewModel)127     public void setViewModel(BatteryChartViewModel viewModel) {
128         if (viewModel == null) {
129             mViewModel = null;
130             invalidate();
131             return;
132         }
133 
134         Log.d(
135                 TAG,
136                 String.format(
137                         "setViewModel(): size: %d, selectedIndex: %d, getHighlightSlotIndex: %d",
138                         viewModel.size(),
139                         viewModel.selectedIndex(),
140                         viewModel.getHighlightSlotIndex()));
141         mViewModel = viewModel;
142         initializeAxisLabelsBounds();
143         initializeTrapezoidSlots(viewModel.size() - 1);
144         setClickable(hasAnyValidTrapezoid(viewModel));
145         requestLayout();
146     }
147 
148     /** Sets the callback to monitor the selected group index. */
setOnSelectListener(BatteryChartView.OnSelectListener listener)149     public void setOnSelectListener(BatteryChartView.OnSelectListener listener) {
150         mOnSelectListener = listener;
151     }
152 
153     /** Sets the companion {@link TextView} for percentage information. */
setCompanionTextView(TextView textView)154     public void setCompanionTextView(TextView textView) {
155         if (textView != null) {
156             // Pre-draws the view first to load style atttributions into paint.
157             textView.draw(new Canvas());
158             mTextPaint = textView.getPaint();
159             mDefaultTextColor = mTextPaint.getColor();
160         } else {
161             mTextPaint = null;
162         }
163         requestLayout();
164     }
165 
166     @Override
onMeasure(int widthMeasureSpec, int heightMeasureSpec)167     public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
168         super.onMeasure(widthMeasureSpec, heightMeasureSpec);
169         // Measures text bounds and updates indent configuration.
170         if (mTextPaint != null) {
171             mTextPaint.setTextAlign(Paint.Align.LEFT);
172             for (int index = 0; index < mPercentages.length; index++) {
173                 mTextPaint.getTextBounds(
174                         mPercentages[index],
175                         0,
176                         mPercentages[index].length(),
177                         mPercentageBounds[index]);
178             }
179             // Updates the indent configurations.
180             mIndent.top = mPercentageBounds[0].height() + mTransomViewHeight;
181             final int textWidth = mPercentageBounds[0].width() + mTextPadding;
182             if (isRTL()) {
183                 mIndent.left = textWidth;
184             } else {
185                 mIndent.right = textWidth;
186             }
187 
188             if (mViewModel != null) {
189                 int maxTop = 0;
190                 for (int index = 0; index < mViewModel.size(); index++) {
191                     final String text = mViewModel.getText(index);
192                     mTextPaint.getTextBounds(text, 0, text.length(), mAxisLabelsBounds.get(index));
193                     maxTop = Math.max(maxTop, -mAxisLabelsBounds.get(index).top);
194                 }
195                 mIndent.bottom = maxTop + round(mTextPadding * 2f);
196             }
197             Log.d(TAG, "setIndent:" + mPercentageBounds[0]);
198         } else {
199             mIndent.set(0, 0, 0, 0);
200         }
201     }
202 
203     @Override
draw(Canvas canvas)204     public void draw(Canvas canvas) {
205         super.draw(canvas);
206         // Before mLevels initialized, the count of trapezoids is unknown. Only draws the
207         // horizontal percentages and dividers.
208         drawHorizontalDividers(canvas);
209         if (mViewModel == null) {
210             return;
211         }
212         drawVerticalDividers(canvas);
213         drawTrapezoids(canvas);
214         drawTransomLine(canvas);
215     }
216 
217     @Override
onTouchEvent(MotionEvent event)218     public boolean onTouchEvent(MotionEvent event) {
219         // Caches the location to calculate selected trapezoid index.
220         final int action = event.getAction();
221         switch (action) {
222             case MotionEvent.ACTION_UP:
223                 mTouchUpEventX = event.getX();
224                 break;
225             case MotionEvent.ACTION_CANCEL:
226                 mTouchUpEventX = Float.MIN_VALUE; // reset
227                 break;
228         }
229         return super.onTouchEvent(event);
230     }
231 
232     @Override
onHoverEvent(MotionEvent event)233     public boolean onHoverEvent(MotionEvent event) {
234         final int action = event.getAction();
235         switch (action) {
236             case MotionEvent.ACTION_HOVER_ENTER:
237             case MotionEvent.ACTION_HOVER_MOVE:
238                 final int trapezoidIndex = getTrapezoidIndex(event.getX());
239                 if (mHoveredIndex != trapezoidIndex) {
240                     mHoveredIndex = trapezoidIndex;
241                     invalidate();
242                     sendAccessibilityEventForHover(AccessibilityEvent.TYPE_VIEW_HOVER_ENTER);
243                 }
244                 // Ignore the super.onHoverEvent() because the hovered trapezoid has already been
245                 // sent here.
246                 return true;
247             case MotionEvent.ACTION_HOVER_EXIT:
248                 if (mHoveredIndex != BatteryChartViewModel.SELECTED_INDEX_INVALID) {
249                     sendAccessibilityEventForHover(AccessibilityEvent.TYPE_VIEW_HOVER_EXIT);
250                     mHoveredIndex = BatteryChartViewModel.SELECTED_INDEX_INVALID; // reset
251                     invalidate();
252                 }
253                 // Ignore the super.onHoverEvent() because the hovered trapezoid has already been
254                 // sent here.
255                 return true;
256             default:
257                 return super.onTouchEvent(event);
258         }
259     }
260 
261     @Override
onHoverChanged(boolean hovered)262     public void onHoverChanged(boolean hovered) {
263         super.onHoverChanged(hovered);
264         if (!hovered) {
265             mHoveredIndex = BatteryChartViewModel.SELECTED_INDEX_INVALID; // reset
266             invalidate();
267         }
268     }
269 
270     @Override
onClick(View view)271     public void onClick(View view) {
272         if (mTouchUpEventX == Float.MIN_VALUE) {
273             Log.w(TAG, "invalid motion event for onClick() callback");
274             return;
275         }
276         onTrapezoidClicked(view, getTrapezoidIndex(mTouchUpEventX));
277     }
278 
279     @Override
getAccessibilityNodeProvider()280     public AccessibilityNodeProvider getAccessibilityNodeProvider() {
281         if (mViewModel == null) {
282             return super.getAccessibilityNodeProvider();
283         }
284         if (mAccessibilityNodeProvider == null) {
285             mAccessibilityNodeProvider = new BatteryChartAccessibilityNodeProvider();
286         }
287         return mAccessibilityNodeProvider;
288     }
289 
onTrapezoidClicked(View view, int index)290     private void onTrapezoidClicked(View view, int index) {
291         // Ignores the click event if the level is zero.
292         if (!isValidToDraw(mViewModel, index)) {
293             return;
294         }
295         if (mOnSelectListener != null) {
296             // Selects all if users click the same trapezoid item two times.
297             mOnSelectListener.onSelect(
298                     index == mViewModel.selectedIndex()
299                             ? BatteryChartViewModel.SELECTED_INDEX_ALL
300                             : index);
301         }
302         view.performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK);
303     }
304 
sendAccessibilityEvent(int virtualDescendantId, int eventType)305     private boolean sendAccessibilityEvent(int virtualDescendantId, int eventType) {
306         ViewParent parent = getParent();
307         if (parent == null || !AccessibilityManager.getInstance(mContext).isEnabled()) {
308             return false;
309         }
310         AccessibilityEvent accessibilityEvent = new AccessibilityEvent(eventType);
311         accessibilityEvent.setSource(this, virtualDescendantId);
312         accessibilityEvent.setEnabled(true);
313         accessibilityEvent.setClassName(getAccessibilityClassName());
314         accessibilityEvent.setPackageName(getContext().getPackageName());
315         return parent.requestSendAccessibilityEvent(this, accessibilityEvent);
316     }
317 
sendAccessibilityEventForHover(int eventType)318     private void sendAccessibilityEventForHover(int eventType) {
319         if (isTrapezoidIndexValid(mViewModel, mHoveredIndex)) {
320             sendAccessibilityEvent(mHoveredIndex, eventType);
321         }
322     }
323 
initializeTrapezoidSlots(int count)324     private void initializeTrapezoidSlots(int count) {
325         mTrapezoidSlots = new TrapezoidSlot[count];
326         for (int index = 0; index < mTrapezoidSlots.length; index++) {
327             mTrapezoidSlots[index] = new TrapezoidSlot();
328         }
329     }
330 
initializeColors(Context context)331     private void initializeColors(Context context) {
332         setBackgroundColor(Color.TRANSPARENT);
333         mTrapezoidSolidColor = Utils.getColorAccentDefaultColor(context);
334         mTrapezoidColor = Utils.getDisabled(context, mTrapezoidSolidColor);
335         mTrapezoidHoverColor =
336                 Utils.getColorAttrDefaultColor(
337                         context, com.android.internal.R.attr.materialColorSecondaryContainer);
338         // Initializes the divider line paint.
339         final Resources resources = getContext().getResources();
340         mDividerWidth = resources.getDimensionPixelSize(R.dimen.chartview_divider_width);
341         mDividerHeight = resources.getDimensionPixelSize(R.dimen.chartview_divider_height);
342         mDividerPaint = new Paint();
343         mDividerPaint.setAntiAlias(true);
344         mDividerPaint.setColor(DIVIDER_COLOR);
345         mDividerPaint.setStyle(Paint.Style.STROKE);
346         mDividerPaint.setStrokeWidth(mDividerWidth);
347         Log.i(TAG, "mDividerWidth:" + mDividerWidth);
348         Log.i(TAG, "mDividerHeight:" + mDividerHeight);
349         // Initializes the trapezoid paint.
350         mTrapezoidHOffset = resources.getDimension(R.dimen.chartview_trapezoid_margin_start);
351         mTrapezoidVOffset = resources.getDimension(R.dimen.chartview_trapezoid_margin_bottom);
352         mTrapezoidPaint = new Paint();
353         mTrapezoidPaint.setAntiAlias(true);
354         mTrapezoidPaint.setColor(mTrapezoidSolidColor);
355         mTrapezoidPaint.setStyle(Paint.Style.FILL);
356         mTrapezoidPaint.setPathEffect(
357                 new CornerPathEffect(
358                         resources.getDimensionPixelSize(R.dimen.chartview_trapezoid_radius)));
359         // Initializes for drawing text information.
360         mTextPadding = resources.getDimensionPixelSize(R.dimen.chartview_text_padding);
361         // Initializes the padding top for drawing text information.
362         mTransomViewHeight =
363                 resources.getDimensionPixelSize(R.dimen.chartview_transom_layout_height);
364     }
365 
initializeTransomPaint()366     private void initializeTransomPaint() {
367         if (mTransomLinePaint != null
368                 && mTransomSelectedSlotPaint != null
369                 && mTransomIcon != null) {
370             return;
371         }
372         // Initializes the transom line paint.
373         final Resources resources = getContext().getResources();
374         final int transomLineWidth =
375                 resources.getDimensionPixelSize(R.dimen.chartview_transom_width);
376         final int transomRadius = resources.getDimensionPixelSize(R.dimen.chartview_transom_radius);
377         mTransomPadding = transomRadius * .5f;
378         mTransomTop = resources.getDimensionPixelSize(R.dimen.chartview_transom_padding_top);
379         mTransomLineDefaultColor = Utils.getDisabled(mContext, DIVIDER_COLOR);
380         mTransomLineSelectedColor =
381                 resources.getColor(R.color.color_battery_anomaly_app_warning_selector);
382         final int slotHighlightColor = Utils.getDisabled(mContext, mTransomLineSelectedColor);
383         mTransomIconSize = resources.getDimensionPixelSize(R.dimen.chartview_transom_icon_size);
384         mTransomLinePaint = new Paint();
385         mTransomLinePaint.setAntiAlias(true);
386         mTransomLinePaint.setStyle(Paint.Style.STROKE);
387         mTransomLinePaint.setStrokeWidth(transomLineWidth);
388         mTransomLinePaint.setStrokeCap(Paint.Cap.ROUND);
389         mTransomLinePaint.setPathEffect(new CornerPathEffect(transomRadius));
390         mTransomSelectedSlotPaint = new Paint();
391         mTransomSelectedSlotPaint.setAntiAlias(true);
392         mTransomSelectedSlotPaint.setColor(slotHighlightColor);
393         mTransomSelectedSlotPaint.setStyle(Paint.Style.FILL);
394         // Get the companion icon beside transom line
395         mTransomIcon = getResources().getDrawable(R.drawable.ic_battery_tips_warning_icon);
396     }
397 
drawHorizontalDividers(Canvas canvas)398     private void drawHorizontalDividers(Canvas canvas) {
399         final int width = getWidth() - abs(mIndent.width());
400         final int height = getHeight() - mIndent.top - mIndent.bottom;
401         final float topOffsetY = mIndent.top + mDividerWidth * .5f;
402         final float bottomOffsetY = mIndent.top + (height - mDividerHeight - mDividerWidth * .5f);
403         final float availableSpace = bottomOffsetY - topOffsetY;
404 
405         mDividerPaint.setColor(DIVIDER_COLOR);
406         final float dividerOffsetUnit = availableSpace / (float) (HORIZONTAL_DIVIDER_COUNT - 1);
407 
408         // Draws 5 divider lines.
409         for (int index = 0; index < HORIZONTAL_DIVIDER_COUNT; index++) {
410             float offsetY = topOffsetY + dividerOffsetUnit * index;
411             canvas.drawLine(mIndent.left, offsetY, mIndent.left + width, offsetY, mDividerPaint);
412 
413             //  Draws percentage text only for 100% / 50% / 0%
414             if (index % 2 == 0) {
415                 drawPercentage(canvas, /* index= */ (index + 1) / 2, offsetY);
416             }
417         }
418     }
419 
drawPercentage(Canvas canvas, int index, float offsetY)420     private void drawPercentage(Canvas canvas, int index, float offsetY) {
421         if (mTextPaint != null) {
422             mTextPaint.setTextAlign(isRTL() ? Paint.Align.RIGHT : Paint.Align.LEFT);
423             mTextPaint.setColor(mDefaultTextColor);
424             canvas.drawText(
425                     mPercentages[index],
426                     isRTL()
427                             ? mIndent.left - mTextPadding
428                             : getWidth() - mIndent.width() + mTextPadding,
429                     offsetY + mPercentageBounds[index].height() * .5f,
430                     mTextPaint);
431         }
432     }
433 
drawVerticalDividers(Canvas canvas)434     private void drawVerticalDividers(Canvas canvas) {
435         final int width = getWidth() - abs(mIndent.width());
436         final int dividerCount = mTrapezoidSlots.length + 1;
437         final float dividerSpace = dividerCount * mDividerWidth;
438         final float unitWidth = (width - dividerSpace) / (float) mTrapezoidSlots.length;
439         final float bottomY = getHeight() - mIndent.bottom;
440         final float startY = bottomY - mDividerHeight;
441         final float trapezoidSlotOffset = mTrapezoidHOffset + mDividerWidth * .5f;
442         // Draws the axis label slot information.
443         if (mViewModel != null) {
444             final float baselineY = getHeight() - mTextPadding;
445             Rect[] axisLabelDisplayAreas;
446             switch (mViewModel.axisLabelPosition()) {
447                 case CENTER_OF_TRAPEZOIDS:
448                     axisLabelDisplayAreas =
449                             getAxisLabelDisplayAreas(
450                                     /* size= */ mViewModel.size() - 1,
451                                     /* baselineX= */ mIndent.left + mDividerWidth + unitWidth * .5f,
452                                     /* offsetX= */ mDividerWidth + unitWidth,
453                                     baselineY,
454                                     /* shiftFirstAndLast= */ false);
455                     break;
456                 case BETWEEN_TRAPEZOIDS:
457                 default:
458                     axisLabelDisplayAreas =
459                             getAxisLabelDisplayAreas(
460                                     /* size= */ mViewModel.size(),
461                                     /* baselineX= */ mIndent.left + mDividerWidth * .5f,
462                                     /* offsetX= */ mDividerWidth + unitWidth,
463                                     baselineY,
464                                     /* shiftFirstAndLast= */ true);
465                     break;
466             }
467             drawAxisLabels(canvas, axisLabelDisplayAreas, baselineY);
468         }
469         // Draws each vertical dividers.
470         float startX = mDividerWidth * .5f + mIndent.left;
471         for (int index = 0; index < dividerCount; index++) {
472             float dividerY = bottomY;
473             if (mViewModel.axisLabelPosition() == BETWEEN_TRAPEZOIDS
474                     && mLabelDrawnIndexes.contains(index)) {
475                 mDividerPaint.setColor(mTrapezoidSolidColor);
476                 dividerY += mDividerHeight / 4f;
477             } else {
478                 mDividerPaint.setColor(DIVIDER_COLOR);
479             }
480             canvas.drawLine(startX, startY, startX, dividerY, mDividerPaint);
481             final float nextX = startX + mDividerWidth + unitWidth;
482             // Updates the trapezoid slots for drawing.
483             if (index < mTrapezoidSlots.length) {
484                 final int trapezoidIndex = isRTL() ? mTrapezoidSlots.length - index - 1 : index;
485                 mTrapezoidSlots[trapezoidIndex].mLeft = round(startX + trapezoidSlotOffset);
486                 mTrapezoidSlots[trapezoidIndex].mRight = round(nextX - trapezoidSlotOffset);
487             }
488             startX = nextX;
489         }
490     }
491 
492     /** Gets all the axis label texts displaying area positions if they are shown. */
getAxisLabelDisplayAreas( final int size, final float baselineX, final float offsetX, final float baselineY, final boolean shiftFirstAndLast)493     private Rect[] getAxisLabelDisplayAreas(
494             final int size,
495             final float baselineX,
496             final float offsetX,
497             final float baselineY,
498             final boolean shiftFirstAndLast) {
499         final Rect[] result = new Rect[size];
500         for (int index = 0; index < result.length; index++) {
501             final float width = mAxisLabelsBounds.get(index).width();
502             float middle = baselineX + index * offsetX;
503             if (shiftFirstAndLast) {
504                 if (index == 0) {
505                     middle += width * .5f;
506                 }
507                 if (index == size - 1) {
508                     middle -= width * .5f;
509                 }
510             }
511             final float left = middle - width * .5f;
512             final float right = left + width;
513             final float top = baselineY + mAxisLabelsBounds.get(index).top;
514             final float bottom = top + mAxisLabelsBounds.get(index).height();
515             result[index] = new Rect(round(left), round(top), round(right), round(bottom));
516         }
517         return result;
518     }
519 
drawAxisLabels(Canvas canvas, final Rect[] displayAreas, final float baselineY)520     private void drawAxisLabels(Canvas canvas, final Rect[] displayAreas, final float baselineY) {
521         final int lastIndex = displayAreas.length - 1;
522         mLabelDrawnIndexes.clear();
523         // Suppose first and last labels are always able to draw.
524         drawAxisLabelText(canvas, 0, displayAreas[0], baselineY);
525         mLabelDrawnIndexes.add(0);
526         drawAxisLabelText(canvas, lastIndex, displayAreas[lastIndex], baselineY);
527         mLabelDrawnIndexes.add(lastIndex);
528         drawAxisLabelsBetweenStartIndexAndEndIndex(canvas, displayAreas, 0, lastIndex, baselineY);
529     }
530 
531     /**
532      * Recursively draws axis labels between the start index and the end index. If the inner number
533      * can be exactly divided into 2 parts, check and draw the middle index label and then
534      * recursively draw the 2 parts. Otherwise, divide into 3 parts. Check and draw the middle two
535      * labels and then recursively draw the 3 parts. If there are any overlaps, skip drawing and go
536      * back to the uplevel of the recursion.
537      */
drawAxisLabelsBetweenStartIndexAndEndIndex( Canvas canvas, final Rect[] displayAreas, final int startIndex, final int endIndex, final float baselineY)538     private void drawAxisLabelsBetweenStartIndexAndEndIndex(
539             Canvas canvas,
540             final Rect[] displayAreas,
541             final int startIndex,
542             final int endIndex,
543             final float baselineY) {
544         if (endIndex - startIndex <= 1) {
545             return;
546         }
547         if ((endIndex - startIndex) % 2 == 0) {
548             int middleIndex = (startIndex + endIndex) / 2;
549             if (hasOverlap(displayAreas, startIndex, middleIndex)
550                     || hasOverlap(displayAreas, middleIndex, endIndex)) {
551                 return;
552             }
553             drawAxisLabelText(canvas, middleIndex, displayAreas[middleIndex], baselineY);
554             mLabelDrawnIndexes.add(middleIndex);
555             drawAxisLabelsBetweenStartIndexAndEndIndex(
556                     canvas, displayAreas, startIndex, middleIndex, baselineY);
557             drawAxisLabelsBetweenStartIndexAndEndIndex(
558                     canvas, displayAreas, middleIndex, endIndex, baselineY);
559         } else {
560             int middleIndex1 = startIndex + round((endIndex - startIndex) / 3f);
561             int middleIndex2 = startIndex + round((endIndex - startIndex) * 2 / 3f);
562             if (hasOverlap(displayAreas, startIndex, middleIndex1)
563                     || hasOverlap(displayAreas, middleIndex1, middleIndex2)
564                     || hasOverlap(displayAreas, middleIndex2, endIndex)) {
565                 return;
566             }
567             drawAxisLabelText(canvas, middleIndex1, displayAreas[middleIndex1], baselineY);
568             mLabelDrawnIndexes.add(middleIndex1);
569             drawAxisLabelText(canvas, middleIndex2, displayAreas[middleIndex2], baselineY);
570             mLabelDrawnIndexes.add(middleIndex2);
571             drawAxisLabelsBetweenStartIndexAndEndIndex(
572                     canvas, displayAreas, startIndex, middleIndex1, baselineY);
573             drawAxisLabelsBetweenStartIndexAndEndIndex(
574                     canvas, displayAreas, middleIndex1, middleIndex2, baselineY);
575             drawAxisLabelsBetweenStartIndexAndEndIndex(
576                     canvas, displayAreas, middleIndex2, endIndex, baselineY);
577         }
578     }
579 
hasOverlap( final Rect[] displayAreas, final int leftIndex, final int rightIndex)580     private boolean hasOverlap(
581             final Rect[] displayAreas, final int leftIndex, final int rightIndex) {
582         return displayAreas[leftIndex].right + mTextPadding * 2.3f > displayAreas[rightIndex].left;
583     }
584 
isRTL()585     private boolean isRTL() {
586         return mLayoutDirection == View.LAYOUT_DIRECTION_RTL;
587     }
588 
drawAxisLabelText( Canvas canvas, int index, final Rect displayArea, final float baselineY)589     private void drawAxisLabelText(
590             Canvas canvas, int index, final Rect displayArea, final float baselineY) {
591         mTextPaint.setColor(mTrapezoidSolidColor);
592         mTextPaint.setTextAlign(Paint.Align.CENTER);
593         // Reverse the sort of axis labels for RTL
594         if (isRTL()) {
595             index =
596                     mViewModel.axisLabelPosition() == BETWEEN_TRAPEZOIDS
597                             ? mViewModel.size() - index - 1 // for hourly
598                             : mViewModel.size() - index - 2; // for daily
599         }
600         canvas.drawText(mViewModel.getText(index), displayArea.centerX(), baselineY, mTextPaint);
601         mLabelDrawnIndexes.add(index);
602     }
603 
drawTrapezoids(Canvas canvas)604     private void drawTrapezoids(Canvas canvas) {
605         // Ignores invalid trapezoid data.
606         if (mViewModel == null) {
607             return;
608         }
609         final float trapezoidBottom =
610                 getHeight() - mIndent.bottom - mDividerHeight - mDividerWidth - mTrapezoidVOffset;
611         final float availableSpace =
612                 trapezoidBottom - mDividerWidth * .5f - mIndent.top - mTrapezoidVOffset;
613         final float unitHeight = availableSpace / 100f;
614         // Draws all trapezoid shapes into the canvas.
615         final Path trapezoidPath = new Path();
616         Path trapezoidCurvePath = null;
617         for (int index = 0; index < mTrapezoidSlots.length; index++) {
618             // Not draws the trapezoid for corner or not initialization cases.
619             if (!isValidToDraw(mViewModel, index)) {
620                 continue;
621             }
622             // Configures the trapezoid paint color.
623             final int trapezoidColor =
624                     (mViewModel.selectedIndex() == index
625                                     || mViewModel.selectedIndex()
626                                             == BatteryChartViewModel.SELECTED_INDEX_ALL)
627                             ? mTrapezoidSolidColor
628                             : mTrapezoidColor;
629             final boolean isHoverState =
630                     mHoveredIndex == index && isValidToDraw(mViewModel, mHoveredIndex);
631             mTrapezoidPaint.setColor(isHoverState ? mTrapezoidHoverColor : trapezoidColor);
632 
633             float leftTop =
634                     round(
635                             trapezoidBottom
636                                     - requireNonNull(mViewModel.getLevel(index)) * unitHeight);
637             float rightTop =
638                     round(
639                             trapezoidBottom
640                                     - requireNonNull(mViewModel.getLevel(index + 1)) * unitHeight);
641             // Mirror the shape of the trapezoid for RTL
642             if (isRTL()) {
643                 float temp = leftTop;
644                 leftTop = rightTop;
645                 rightTop = temp;
646             }
647             trapezoidPath.reset();
648             trapezoidPath.moveTo(mTrapezoidSlots[index].mLeft, trapezoidBottom);
649             trapezoidPath.lineTo(mTrapezoidSlots[index].mLeft, leftTop);
650             trapezoidPath.lineTo(mTrapezoidSlots[index].mRight, rightTop);
651             trapezoidPath.lineTo(mTrapezoidSlots[index].mRight, trapezoidBottom);
652             // A tricky way to make the trapezoid shape drawing the rounded corner.
653             trapezoidPath.lineTo(mTrapezoidSlots[index].mLeft, trapezoidBottom);
654             trapezoidPath.lineTo(mTrapezoidSlots[index].mLeft, leftTop);
655             // Draws the trapezoid shape into canvas.
656             canvas.drawPath(trapezoidPath, mTrapezoidPaint);
657         }
658     }
659 
isHighlightSlotValid()660     private boolean isHighlightSlotValid() {
661         return mViewModel != null
662                 && mViewModel.getHighlightSlotIndex()
663                         != BatteryChartViewModel.SELECTED_INDEX_INVALID;
664     }
665 
drawTransomLine(Canvas canvas)666     private void drawTransomLine(Canvas canvas) {
667         if (!isHighlightSlotValid()) {
668             return;
669         }
670         initializeTransomPaint();
671         // Draw the whole transom line and a warning icon
672         mTransomLinePaint.setColor(mTransomLineDefaultColor);
673         final int width = getWidth() - abs(mIndent.width());
674         final float transomOffset = mTrapezoidHOffset + mDividerWidth * .5f + mTransomPadding;
675         final float trapezoidBottom =
676                 getHeight() - mIndent.bottom - mDividerHeight - mDividerWidth - mTrapezoidVOffset;
677         canvas.drawLine(
678                 mIndent.left + transomOffset,
679                 mTransomTop,
680                 mIndent.left + width - transomOffset,
681                 mTransomTop,
682                 mTransomLinePaint);
683         drawTransomIcon(canvas);
684         // Draw selected segment of transom line and a highlight slot
685         mTransomLinePaint.setColor(mTransomLineSelectedColor);
686         final int index = mViewModel.getHighlightSlotIndex();
687         final float startX = mTrapezoidSlots[index].mLeft;
688         final float endX = mTrapezoidSlots[index].mRight;
689         canvas.drawLine(
690                 startX + mTransomPadding,
691                 mTransomTop,
692                 endX - mTransomPadding,
693                 mTransomTop,
694                 mTransomLinePaint);
695         canvas.drawRect(startX, mTransomTop, endX, trapezoidBottom, mTransomSelectedSlotPaint);
696     }
697 
drawTransomIcon(Canvas canvas)698     private void drawTransomIcon(Canvas canvas) {
699         if (mTransomIcon == null) {
700             return;
701         }
702         final int left =
703                 isRTL()
704                         ? mIndent.left - mTextPadding - mTransomIconSize
705                         : getWidth() - abs(mIndent.width()) + mTextPadding;
706         mTransomIcon.setBounds(
707                 left,
708                 mTransomTop - mTransomIconSize / 2,
709                 left + mTransomIconSize,
710                 mTransomTop + mTransomIconSize / 2);
711         mTransomIcon.draw(canvas);
712     }
713 
714     // Searches the corresponding trapezoid index from x location.
getTrapezoidIndex(float x)715     private int getTrapezoidIndex(float x) {
716         if (mTrapezoidSlots == null) {
717             return BatteryChartViewModel.SELECTED_INDEX_INVALID;
718         }
719         for (int index = 0; index < mTrapezoidSlots.length; index++) {
720             final TrapezoidSlot slot = mTrapezoidSlots[index];
721             if (x >= slot.mLeft - mTrapezoidHOffset && x <= slot.mRight + mTrapezoidHOffset) {
722                 return index;
723             }
724         }
725         return BatteryChartViewModel.SELECTED_INDEX_INVALID;
726     }
727 
initializeAxisLabelsBounds()728     private void initializeAxisLabelsBounds() {
729         mAxisLabelsBounds.clear();
730         for (int i = 0; i < mViewModel.size(); i++) {
731             mAxisLabelsBounds.add(new Rect());
732         }
733     }
734 
isTrapezoidValid( @onNull BatteryChartViewModel viewModel, int trapezoidIndex)735     private static boolean isTrapezoidValid(
736             @NonNull BatteryChartViewModel viewModel, int trapezoidIndex) {
737         return viewModel.getLevel(trapezoidIndex) != BATTERY_LEVEL_UNKNOWN
738                 && viewModel.getLevel(trapezoidIndex + 1) != BATTERY_LEVEL_UNKNOWN;
739     }
740 
isTrapezoidIndexValid( @onNull BatteryChartViewModel viewModel, int trapezoidIndex)741     private static boolean isTrapezoidIndexValid(
742             @NonNull BatteryChartViewModel viewModel, int trapezoidIndex) {
743         return viewModel != null && trapezoidIndex >= 0 && trapezoidIndex < viewModel.size() - 1;
744     }
745 
isValidToDraw(BatteryChartViewModel viewModel, int trapezoidIndex)746     private static boolean isValidToDraw(BatteryChartViewModel viewModel, int trapezoidIndex) {
747         return isTrapezoidIndexValid(viewModel, trapezoidIndex)
748                 && isTrapezoidValid(viewModel, trapezoidIndex);
749     }
750 
hasAnyValidTrapezoid(@onNull BatteryChartViewModel viewModel)751     private static boolean hasAnyValidTrapezoid(@NonNull BatteryChartViewModel viewModel) {
752         // Sets the chart is clickable if there is at least one valid item in it.
753         for (int trapezoidIndex = 0; trapezoidIndex < viewModel.size() - 1; trapezoidIndex++) {
754             if (isTrapezoidValid(viewModel, trapezoidIndex)) {
755                 return true;
756             }
757         }
758         return false;
759     }
760 
getPercentages()761     private static String[] getPercentages() {
762         return new String[] {
763             formatPercentage(/* percentage= */ 100, /* round= */ true),
764             formatPercentage(/* percentage= */ 50, /* round= */ true),
765             formatPercentage(/* percentage= */ 0, /* round= */ true)
766         };
767     }
768 
769     private class BatteryChartAccessibilityNodeProvider extends AccessibilityNodeProvider {
770         private static final int UNDEFINED = Integer.MIN_VALUE;
771 
772         private int mAccessibilityFocusNodeViewId = UNDEFINED;
773 
774         @Override
createAccessibilityNodeInfo(int virtualViewId)775         public AccessibilityNodeInfo createAccessibilityNodeInfo(int virtualViewId) {
776             if (virtualViewId == AccessibilityNodeProvider.HOST_VIEW_ID) {
777                 final AccessibilityNodeInfo hostInfo =
778                         new AccessibilityNodeInfo(BatteryChartView.this);
779                 for (int index = 0; index < mViewModel.size() - 1; index++) {
780                     hostInfo.addChild(BatteryChartView.this, index);
781                 }
782                 return hostInfo;
783             }
784             final int index = virtualViewId;
785             if (!isTrapezoidIndexValid(mViewModel, index)) {
786                 Log.w(TAG, "Invalid virtual view id:" + index);
787                 return null;
788             }
789             final AccessibilityNodeInfo childInfo =
790                     new AccessibilityNodeInfo(BatteryChartView.this, index);
791             final String slotTimeInfo = mViewModel.getContentDescription(index);
792             final String batteryLevelInfo = mViewModel.getSlotBatteryLevelText(index);
793             onInitializeAccessibilityNodeInfo(childInfo);
794             childInfo.setClickable(isValidToDraw(mViewModel, index));
795             childInfo.setText(slotTimeInfo);
796             childInfo.setContentDescription(
797                     mContext.getString(
798                             R.string.battery_usage_time_info_and_battery_level,
799                             slotTimeInfo,
800                             batteryLevelInfo));
801             childInfo.setAccessibilityFocused(virtualViewId == mAccessibilityFocusNodeViewId);
802 
803             final Rect bounds = new Rect();
804             getBoundsOnScreen(bounds, true);
805             final int hostLeft = bounds.left;
806             bounds.left = round(hostLeft + mTrapezoidSlots[index].mLeft);
807             bounds.right = round(hostLeft + mTrapezoidSlots[index].mRight);
808             childInfo.setBoundsInScreen(bounds);
809             return childInfo;
810         }
811 
812         @Override
performAction(int virtualViewId, int action, @Nullable Bundle arguments)813         public boolean performAction(int virtualViewId, int action, @Nullable Bundle arguments) {
814             if (virtualViewId == AccessibilityNodeProvider.HOST_VIEW_ID) {
815                 return performAccessibilityAction(action, arguments);
816             }
817             switch (action) {
818                 case AccessibilityNodeInfo.ACTION_CLICK:
819                     onTrapezoidClicked(BatteryChartView.this, virtualViewId);
820                     return true;
821 
822                 case AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS:
823                     mAccessibilityFocusNodeViewId = virtualViewId;
824                     return sendAccessibilityEvent(
825                             virtualViewId, AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED);
826 
827                 case AccessibilityNodeInfo.ACTION_CLEAR_ACCESSIBILITY_FOCUS:
828                     if (mAccessibilityFocusNodeViewId == virtualViewId) {
829                         mAccessibilityFocusNodeViewId = UNDEFINED;
830                     }
831                     return sendAccessibilityEvent(
832                             virtualViewId,
833                             AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED);
834 
835                 default:
836                     return performAccessibilityAction(action, arguments);
837             }
838         }
839     }
840 
841     // A container class for each trapezoid left and right location.
842     @VisibleForTesting
843     static final class TrapezoidSlot {
844         public float mLeft;
845         public float mRight;
846 
847         @Override
toString()848         public String toString() {
849             return String.format(Locale.US, "TrapezoidSlot[%f,%f]", mLeft, mRight);
850         }
851     }
852 }
853