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