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