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