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.settings.R;
38import com.android.settings.widget.ChartSweepView.OnSweepListener;
39
40import java.util.Arrays;
41import java.util.Calendar;
42import 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 */
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 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 {
71        public void onWarningChanged();
72        public void onLimitChanged();
73        public void requestWarningEdit();
74        public void requestLimitEdit();
75    }
76
77    private DataUsageChartListener mListener;
78
79    public ChartDataUsageView(Context context) {
80        this(context, null, 0);
81    }
82
83    public ChartDataUsageView(Context context, AttributeSet attrs) {
84        this(context, attrs, 0);
85    }
86
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
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
140    public void setListener(DataUsageChartListener listener) {
141        mListener = listener;
142    }
143
144    public void bindNetworkStats(NetworkStatsHistory stats) {
145        mSeries.bindNetworkStats(stats);
146        mHistory = stats;
147        updateVertAxisBounds(null);
148        updateEstimateVisible();
149        updatePrimaryRange();
150        requestLayout();
151    }
152
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
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     */
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     */
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
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
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
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
336    public long getInspectStart() {
337        return mInspectStart;
338    }
339
340    public long getInspectEnd() {
341        return mInspectEnd;
342    }
343
344    public long getWarningBytes() {
345        return mSweepWarning.getLabelValue();
346    }
347
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     */
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
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
393        public TimeAxis() {
394            final long currentTime = System.currentTimeMillis();
395            setBounds(currentTime - DateUtils.DAY_IN_MILLIS * 30, currentTime);
396        }
397
398        @Override
399        public int hashCode() {
400            return Objects.hash(mMin, mMax, mSize);
401        }
402
403        @Override
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
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
425        public float convertToPoint(long value) {
426            return (mSize * (value - mMin)) / (mMax - mMin);
427        }
428
429        @Override
430        public long convertToValue(float point) {
431            return (long) (mMin + ((point * (mMax - mMin)) / mSize));
432        }
433
434        @Override
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
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
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
481        public int hashCode() {
482            return Objects.hash(mMin, mMax, mSize);
483        }
484
485        @Override
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
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
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
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
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
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
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
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
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