1 /*
2  * Copyright (C) 2011 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.settings.widget;
18 
19 import static android.net.TrafficStats.GB_IN_BYTES;
20 import static android.net.TrafficStats.MB_IN_BYTES;
21 
22 import android.content.Context;
23 import android.content.res.Resources;
24 import android.net.NetworkPolicy;
25 import android.net.NetworkStatsHistory;
26 import android.os.Handler;
27 import android.os.Message;
28 import android.text.Spannable;
29 import android.text.SpannableStringBuilder;
30 import android.text.TextUtils;
31 import android.text.format.DateUtils;
32 import android.text.format.Time;
33 import android.util.AttributeSet;
34 import android.view.MotionEvent;
35 import android.view.View;
36 
37 import com.android.settings.R;
38 import com.android.settings.widget.ChartSweepView.OnSweepListener;
39 
40 import java.util.Arrays;
41 import java.util.Calendar;
42 import java.util.Objects;
43 
44 /**
45  * Specific {@link ChartView} that displays {@link ChartNetworkSeriesView} along
46  * with {@link ChartSweepView} for inspection ranges and warning/limits.
47  */
48 public class ChartDataUsageView extends ChartView {
49 
50     private static final int MSG_UPDATE_AXIS = 100;
51     private static final long DELAY_MILLIS = 250;
52 
53     private ChartGridView mGrid;
54     private ChartNetworkSeriesView mSeries;
55     private ChartNetworkSeriesView mDetailSeries;
56 
57     private NetworkStatsHistory mHistory;
58 
59     private ChartSweepView mSweepWarning;
60     private ChartSweepView mSweepLimit;
61 
62     private long mInspectStart;
63     private long mInspectEnd;
64 
65     private Handler mHandler;
66 
67     /** Current maximum value of {@link #mVert}. */
68     private long mVertMax;
69 
70     public interface DataUsageChartListener {
onWarningChanged()71         public void onWarningChanged();
onLimitChanged()72         public void onLimitChanged();
requestWarningEdit()73         public void requestWarningEdit();
requestLimitEdit()74         public void requestLimitEdit();
75     }
76 
77     private DataUsageChartListener mListener;
78 
ChartDataUsageView(Context context)79     public ChartDataUsageView(Context context) {
80         this(context, null, 0);
81     }
82 
ChartDataUsageView(Context context, AttributeSet attrs)83     public ChartDataUsageView(Context context, AttributeSet attrs) {
84         this(context, attrs, 0);
85     }
86 
ChartDataUsageView(Context context, AttributeSet attrs, int defStyle)87     public ChartDataUsageView(Context context, AttributeSet attrs, int defStyle) {
88         super(context, attrs, defStyle);
89         init(new TimeAxis(), new InvertedChartAxis(new DataAxis()));
90 
91         mHandler = new Handler() {
92             @Override
93             public void handleMessage(Message msg) {
94                 final ChartSweepView sweep = (ChartSweepView) msg.obj;
95                 updateVertAxisBounds(sweep);
96                 updateEstimateVisible();
97 
98                 // we keep dispatching repeating updates until sweep is dropped
99                 sendUpdateAxisDelayed(sweep, true);
100             }
101         };
102     }
103 
104     @Override
onFinishInflate()105     protected void onFinishInflate() {
106         super.onFinishInflate();
107 
108         mGrid = (ChartGridView) findViewById(R.id.grid);
109         mSeries = (ChartNetworkSeriesView) findViewById(R.id.series);
110         mDetailSeries = (ChartNetworkSeriesView) findViewById(R.id.detail_series);
111         mDetailSeries.setVisibility(View.GONE);
112 
113         mSweepLimit = (ChartSweepView) findViewById(R.id.sweep_limit);
114         mSweepWarning = (ChartSweepView) findViewById(R.id.sweep_warning);
115 
116         // prevent sweeps from crossing each other
117         mSweepWarning.setValidRangeDynamic(null, mSweepLimit);
118         mSweepLimit.setValidRangeDynamic(mSweepWarning, null);
119 
120         // mark neighbors for checking touch events against
121         mSweepLimit.setNeighbors(mSweepWarning);
122         mSweepWarning.setNeighbors(mSweepLimit);
123 
124         mSweepWarning.addOnSweepListener(mVertListener);
125         mSweepLimit.addOnSweepListener(mVertListener);
126 
127         mSweepWarning.setDragInterval(5 * MB_IN_BYTES);
128         mSweepLimit.setDragInterval(5 * MB_IN_BYTES);
129 
130         // tell everyone about our axis
131         mGrid.init(mHoriz, mVert);
132         mSeries.init(mHoriz, mVert);
133         mDetailSeries.init(mHoriz, mVert);
134         mSweepWarning.init(mVert);
135         mSweepLimit.init(mVert);
136 
137         setActivated(false);
138     }
139 
setListener(DataUsageChartListener listener)140     public void setListener(DataUsageChartListener listener) {
141         mListener = listener;
142     }
143 
bindNetworkStats(NetworkStatsHistory stats)144     public void bindNetworkStats(NetworkStatsHistory stats) {
145         mSeries.bindNetworkStats(stats);
146         mHistory = stats;
147         updateVertAxisBounds(null);
148         updateEstimateVisible();
149         updatePrimaryRange();
150         requestLayout();
151     }
152 
bindDetailNetworkStats(NetworkStatsHistory stats)153     public void bindDetailNetworkStats(NetworkStatsHistory stats) {
154         mDetailSeries.bindNetworkStats(stats);
155         mDetailSeries.setVisibility(stats != null ? View.VISIBLE : View.GONE);
156         if (mHistory != null) {
157             mDetailSeries.setEndTime(mHistory.getEnd());
158         }
159         updateVertAxisBounds(null);
160         updateEstimateVisible();
161         updatePrimaryRange();
162         requestLayout();
163     }
164 
bindNetworkPolicy(NetworkPolicy policy)165     public void bindNetworkPolicy(NetworkPolicy policy) {
166         if (policy == null) {
167             mSweepLimit.setVisibility(View.INVISIBLE);
168             mSweepLimit.setValue(-1);
169             mSweepWarning.setVisibility(View.INVISIBLE);
170             mSweepWarning.setValue(-1);
171             return;
172         }
173 
174         if (policy.limitBytes != NetworkPolicy.LIMIT_DISABLED) {
175             mSweepLimit.setVisibility(View.VISIBLE);
176             mSweepLimit.setEnabled(true);
177             mSweepLimit.setValue(policy.limitBytes);
178         } else {
179             mSweepLimit.setVisibility(View.INVISIBLE);
180             mSweepLimit.setEnabled(false);
181             mSweepLimit.setValue(-1);
182         }
183 
184         if (policy.warningBytes != NetworkPolicy.WARNING_DISABLED) {
185             mSweepWarning.setVisibility(View.VISIBLE);
186             mSweepWarning.setValue(policy.warningBytes);
187         } else {
188             mSweepWarning.setVisibility(View.INVISIBLE);
189             mSweepWarning.setValue(-1);
190         }
191 
192         updateVertAxisBounds(null);
193         requestLayout();
194         invalidate();
195     }
196 
197     /**
198      * Update {@link #mVert} to both show data from {@link NetworkStatsHistory}
199      * and controls from {@link NetworkPolicy}.
200      */
updateVertAxisBounds(ChartSweepView activeSweep)201     private void updateVertAxisBounds(ChartSweepView activeSweep) {
202         final long max = mVertMax;
203 
204         long newMax = 0;
205         if (activeSweep != null) {
206             final int adjustAxis = activeSweep.shouldAdjustAxis();
207             if (adjustAxis > 0) {
208                 // hovering around upper edge, grow axis
209                 newMax = max * 11 / 10;
210             } else if (adjustAxis < 0) {
211                 // hovering around lower edge, shrink axis
212                 newMax = max * 9 / 10;
213             } else {
214                 newMax = max;
215             }
216         }
217 
218         // always show known data and policy lines
219         final long maxSweep = Math.max(mSweepWarning.getValue(), mSweepLimit.getValue());
220         final long maxSeries = Math.max(mSeries.getMaxVisible(), mDetailSeries.getMaxVisible());
221         final long maxVisible = Math.max(maxSeries, maxSweep) * 12 / 10;
222         final long maxDefault = Math.max(maxVisible, 50 * MB_IN_BYTES);
223         newMax = Math.max(maxDefault, newMax);
224 
225         // only invalidate when vertMax actually changed
226         if (newMax != mVertMax) {
227             mVertMax = newMax;
228 
229             final boolean changed = mVert.setBounds(0L, newMax);
230             mSweepWarning.setValidRange(0L, newMax);
231             mSweepLimit.setValidRange(0L, newMax);
232 
233             if (changed) {
234                 mSeries.invalidatePath();
235                 mDetailSeries.invalidatePath();
236             }
237 
238             mGrid.invalidate();
239 
240             // since we just changed axis, make sweep recalculate its value
241             if (activeSweep != null) {
242                 activeSweep.updateValueFromPosition();
243             }
244 
245             // layout other sweeps to match changed axis
246             // TODO: find cleaner way of doing this, such as requesting full
247             // layout and making activeSweep discard its tracking MotionEvent.
248             if (mSweepLimit != activeSweep) {
249                 layoutSweep(mSweepLimit);
250             }
251             if (mSweepWarning != activeSweep) {
252                 layoutSweep(mSweepWarning);
253             }
254         }
255     }
256 
257     /**
258      * Control {@link ChartNetworkSeriesView#setEstimateVisible(boolean)} based
259      * on how close estimate comes to {@link #mSweepWarning}.
260      */
updateEstimateVisible()261     private void updateEstimateVisible() {
262         final long maxEstimate = mSeries.getMaxEstimate();
263 
264         // show estimate when near warning/limit
265         long interestLine = Long.MAX_VALUE;
266         if (mSweepWarning.isEnabled()) {
267             interestLine = mSweepWarning.getValue();
268         } else if (mSweepLimit.isEnabled()) {
269             interestLine = mSweepLimit.getValue();
270         }
271 
272         if (interestLine < 0) {
273             interestLine = Long.MAX_VALUE;
274         }
275 
276         final boolean estimateVisible = (maxEstimate >= interestLine * 7 / 10);
277         mSeries.setEstimateVisible(estimateVisible);
278     }
279 
sendUpdateAxisDelayed(ChartSweepView sweep, boolean force)280     private void sendUpdateAxisDelayed(ChartSweepView sweep, boolean force) {
281         if (force || !mHandler.hasMessages(MSG_UPDATE_AXIS, sweep)) {
282             mHandler.sendMessageDelayed(
283                     mHandler.obtainMessage(MSG_UPDATE_AXIS, sweep), DELAY_MILLIS);
284         }
285     }
286 
clearUpdateAxisDelayed(ChartSweepView sweep)287     private void clearUpdateAxisDelayed(ChartSweepView sweep) {
288         mHandler.removeMessages(MSG_UPDATE_AXIS, sweep);
289     }
290 
291     private OnSweepListener mVertListener = new OnSweepListener() {
292         @Override
293         public void onSweep(ChartSweepView sweep, boolean sweepDone) {
294             if (sweepDone) {
295                 clearUpdateAxisDelayed(sweep);
296                 updateEstimateVisible();
297 
298                 if (sweep == mSweepWarning && mListener != null) {
299                     mListener.onWarningChanged();
300                 } else if (sweep == mSweepLimit && mListener != null) {
301                     mListener.onLimitChanged();
302                 }
303             } else {
304                 // while moving, kick off delayed grow/shrink axis updates
305                 sendUpdateAxisDelayed(sweep, false);
306             }
307         }
308 
309         @Override
310         public void requestEdit(ChartSweepView sweep) {
311             if (sweep == mSweepWarning && mListener != null) {
312                 mListener.requestWarningEdit();
313             } else if (sweep == mSweepLimit && mListener != null) {
314                 mListener.requestLimitEdit();
315             }
316         }
317     };
318 
319     @Override
onTouchEvent(MotionEvent event)320     public boolean onTouchEvent(MotionEvent event) {
321         if (isActivated()) return false;
322         switch (event.getAction()) {
323             case MotionEvent.ACTION_DOWN: {
324                 return true;
325             }
326             case MotionEvent.ACTION_UP: {
327                 setActivated(true);
328                 return true;
329             }
330             default: {
331                 return false;
332             }
333         }
334     }
335 
getInspectStart()336     public long getInspectStart() {
337         return mInspectStart;
338     }
339 
getInspectEnd()340     public long getInspectEnd() {
341         return mInspectEnd;
342     }
343 
getWarningBytes()344     public long getWarningBytes() {
345         return mSweepWarning.getLabelValue();
346     }
347 
getLimitBytes()348     public long getLimitBytes() {
349         return mSweepLimit.getLabelValue();
350     }
351 
352     /**
353      * Set the exact time range that should be displayed, updating how
354      * {@link ChartNetworkSeriesView} paints. Moves inspection ranges to be the
355      * last "week" of available data, without triggering listener events.
356      */
setVisibleRange(long visibleStart, long visibleEnd)357     public void setVisibleRange(long visibleStart, long visibleEnd) {
358         final boolean changed = mHoriz.setBounds(visibleStart, visibleEnd);
359         mGrid.setBounds(visibleStart, visibleEnd);
360         mSeries.setBounds(visibleStart, visibleEnd);
361         mDetailSeries.setBounds(visibleStart, visibleEnd);
362 
363         mInspectStart = visibleStart;
364         mInspectEnd = visibleEnd;
365 
366         requestLayout();
367         if (changed) {
368             mSeries.invalidatePath();
369             mDetailSeries.invalidatePath();
370         }
371 
372         updateVertAxisBounds(null);
373         updateEstimateVisible();
374         updatePrimaryRange();
375     }
376 
updatePrimaryRange()377     private void updatePrimaryRange() {
378         // prefer showing primary range on detail series, when available
379         if (mDetailSeries.getVisibility() == View.VISIBLE) {
380             mSeries.setSecondary(true);
381         } else {
382             mSeries.setSecondary(false);
383         }
384     }
385 
386     public static class TimeAxis implements ChartAxis {
387         private static final int FIRST_DAY_OF_WEEK = Calendar.getInstance().getFirstDayOfWeek() - 1;
388 
389         private long mMin;
390         private long mMax;
391         private float mSize;
392 
TimeAxis()393         public TimeAxis() {
394             final long currentTime = System.currentTimeMillis();
395             setBounds(currentTime - DateUtils.DAY_IN_MILLIS * 30, currentTime);
396         }
397 
398         @Override
hashCode()399         public int hashCode() {
400             return Objects.hash(mMin, mMax, mSize);
401         }
402 
403         @Override
setBounds(long min, long max)404         public boolean setBounds(long min, long max) {
405             if (mMin != min || mMax != max) {
406                 mMin = min;
407                 mMax = max;
408                 return true;
409             } else {
410                 return false;
411             }
412         }
413 
414         @Override
setSize(float size)415         public boolean setSize(float size) {
416             if (mSize != size) {
417                 mSize = size;
418                 return true;
419             } else {
420                 return false;
421             }
422         }
423 
424         @Override
convertToPoint(long value)425         public float convertToPoint(long value) {
426             return (mSize * (value - mMin)) / (mMax - mMin);
427         }
428 
429         @Override
convertToValue(float point)430         public long convertToValue(float point) {
431             return (long) (mMin + ((point * (mMax - mMin)) / mSize));
432         }
433 
434         @Override
buildLabel(Resources res, SpannableStringBuilder builder, long value)435         public long buildLabel(Resources res, SpannableStringBuilder builder, long value) {
436             // TODO: convert to better string
437             builder.replace(0, builder.length(), Long.toString(value));
438             return value;
439         }
440 
441         @Override
getTickPoints()442         public float[] getTickPoints() {
443             final float[] ticks = new float[32];
444             int i = 0;
445 
446             // tick mark for first day of each week
447             final Time time = new Time();
448             time.set(mMax);
449             time.monthDay -= time.weekDay - FIRST_DAY_OF_WEEK;
450             time.hour = time.minute = time.second = 0;
451 
452             time.normalize(true);
453             long timeMillis = time.toMillis(true);
454             while (timeMillis > mMin) {
455                 if (timeMillis <= mMax) {
456                     ticks[i++] = convertToPoint(timeMillis);
457                 }
458                 time.monthDay -= 7;
459                 time.normalize(true);
460                 timeMillis = time.toMillis(true);
461             }
462 
463             return Arrays.copyOf(ticks, i);
464         }
465 
466         @Override
shouldAdjustAxis(long value)467         public int shouldAdjustAxis(long value) {
468             // time axis never adjusts
469             return 0;
470         }
471     }
472 
473     public static class DataAxis implements ChartAxis {
474         private long mMin;
475         private long mMax;
476         private float mSize;
477 
478         private static final boolean LOG_SCALE = false;
479 
480         @Override
hashCode()481         public int hashCode() {
482             return Objects.hash(mMin, mMax, mSize);
483         }
484 
485         @Override
setBounds(long min, long max)486         public boolean setBounds(long min, long max) {
487             if (mMin != min || mMax != max) {
488                 mMin = min;
489                 mMax = max;
490                 return true;
491             } else {
492                 return false;
493             }
494         }
495 
496         @Override
setSize(float size)497         public boolean setSize(float size) {
498             if (mSize != size) {
499                 mSize = size;
500                 return true;
501             } else {
502                 return false;
503             }
504         }
505 
506         @Override
convertToPoint(long value)507         public float convertToPoint(long value) {
508             if (LOG_SCALE) {
509                 // derived polynomial fit to make lower values more visible
510                 final double normalized = ((double) value - mMin) / (mMax - mMin);
511                 final double fraction = Math.pow(10,
512                         0.36884343106175121463 * Math.log10(normalized) + -0.04328199452018252624);
513                 return (float) (fraction * mSize);
514             } else {
515                 return (mSize * (value - mMin)) / (mMax - mMin);
516             }
517         }
518 
519         @Override
convertToValue(float point)520         public long convertToValue(float point) {
521             if (LOG_SCALE) {
522                 final double normalized = point / mSize;
523                 final double fraction = 1.3102228476089056629
524                         * Math.pow(normalized, 2.7111774693164631640);
525                 return (long) (mMin + (fraction * (mMax - mMin)));
526             } else {
527                 return (long) (mMin + ((point * (mMax - mMin)) / mSize));
528             }
529         }
530 
531         private static final Object sSpanSize = new Object();
532         private static final Object sSpanUnit = new Object();
533 
534         @Override
buildLabel(Resources res, SpannableStringBuilder builder, long value)535         public long buildLabel(Resources res, SpannableStringBuilder builder, long value) {
536 
537             final CharSequence unit;
538             final long unitFactor;
539             if (value < 1000 * MB_IN_BYTES) {
540                 unit = res.getText(com.android.internal.R.string.megabyteShort);
541                 unitFactor = MB_IN_BYTES;
542             } else {
543                 unit = res.getText(com.android.internal.R.string.gigabyteShort);
544                 unitFactor = GB_IN_BYTES;
545             }
546 
547             final double result = (double) value / unitFactor;
548             final double resultRounded;
549             final CharSequence size;
550 
551             if (result < 10) {
552                 size = String.format("%.1f", result);
553                 resultRounded = (unitFactor * Math.round(result * 10)) / 10;
554             } else {
555                 size = String.format("%.0f", result);
556                 resultRounded = unitFactor * Math.round(result);
557             }
558 
559             setText(builder, sSpanSize, size, "^1");
560             setText(builder, sSpanUnit, unit, "^2");
561 
562             return (long) resultRounded;
563         }
564 
565         @Override
getTickPoints()566         public float[] getTickPoints() {
567             final long range = mMax - mMin;
568 
569             // target about 16 ticks on screen, rounded to nearest power of 2
570             final long tickJump = roundUpToPowerOfTwo(range / 16);
571             final int tickCount = (int) (range / tickJump);
572             final float[] tickPoints = new float[tickCount];
573             long value = mMin;
574             for (int i = 0; i < tickPoints.length; i++) {
575                 tickPoints[i] = convertToPoint(value);
576                 value += tickJump;
577             }
578 
579             return tickPoints;
580         }
581 
582         @Override
shouldAdjustAxis(long value)583         public int shouldAdjustAxis(long value) {
584             final float point = convertToPoint(value);
585             if (point < mSize * 0.1) {
586                 return -1;
587             } else if (point > mSize * 0.85) {
588                 return 1;
589             } else {
590                 return 0;
591             }
592         }
593     }
594 
setText( SpannableStringBuilder builder, Object key, CharSequence text, String bootstrap)595     private static void setText(
596             SpannableStringBuilder builder, Object key, CharSequence text, String bootstrap) {
597         int start = builder.getSpanStart(key);
598         int end = builder.getSpanEnd(key);
599         if (start == -1) {
600             start = TextUtils.indexOf(builder, bootstrap);
601             end = start + bootstrap.length();
602             builder.setSpan(key, start, end, Spannable.SPAN_INCLUSIVE_INCLUSIVE);
603         }
604         builder.replace(start, end, text);
605     }
606 
roundUpToPowerOfTwo(long i)607     private static long roundUpToPowerOfTwo(long i) {
608         // NOTE: borrowed from Hashtable.roundUpToPowerOfTwo()
609 
610         i--; // If input is a power of two, shift its high-order bit right
611 
612         // "Smear" the high-order bit all the way to the right
613         i |= i >>>  1;
614         i |= i >>>  2;
615         i |= i >>>  4;
616         i |= i >>>  8;
617         i |= i >>> 16;
618         i |= i >>> 32;
619 
620         i++;
621 
622         return i > 0 ? i : Long.MAX_VALUE;
623     }
624 }
625