platform-packages-apps-Settings / src / com / android / settings / widget / ChartDataUsageView.java
ChartDataUsageView.java
Raw
/*
 * Copyright (C) 2011 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.settings.widget;

import android.content.Context;
import android.content.res.Resources;
import android.net.NetworkPolicy;
import android.net.NetworkStatsHistory;
import android.net.TrafficStats;
import android.os.Handler;
import android.os.Message;
import android.text.Spannable;
import android.text.SpannableStringBuilder;
import android.text.TextUtils;
import android.text.format.DateUtils;
import android.text.format.Formatter;
import android.text.format.Formatter.BytesResult;
import android.text.format.Time;
import android.util.AttributeSet;
import android.util.MathUtils;
import android.view.MotionEvent;
import android.view.View;

import com.android.settings.R;
import com.android.settings.widget.ChartSweepView.OnSweepListener;

import java.util.Arrays;
import java.util.Calendar;
import java.util.Objects;

import static android.net.TrafficStats.MB_IN_BYTES;

/**
 * Specific {@link ChartView} that displays {@link ChartNetworkSeriesView} along
 * with {@link ChartSweepView} for inspection ranges and warning/limits.
 */
public class ChartDataUsageView extends ChartView {

    private static final int MSG_UPDATE_AXIS = 100;
    private static final long DELAY_MILLIS = 250;

    private ChartGridView mGrid;
    private ChartNetworkSeriesView mSeries;
    private ChartNetworkSeriesView mDetailSeries;

    private NetworkStatsHistory mHistory;

    private ChartSweepView mSweepWarning;
    private ChartSweepView mSweepLimit;

    private long mInspectStart;
    private long mInspectEnd;

    private Handler mHandler;

    /** Current maximum value of {@link #mVert}. */
    private long mVertMax;

    public interface DataUsageChartListener {
        public void onWarningChanged();
        public void onLimitChanged();
        public void requestWarningEdit();
        public void requestLimitEdit();
    }

    private DataUsageChartListener mListener;

    public ChartDataUsageView(Context context) {
        this(context, null, 0);
    }

    public ChartDataUsageView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public ChartDataUsageView(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
        init(new TimeAxis(), new InvertedChartAxis(new DataAxis()));

        mHandler = new Handler() {
            @Override
            public void handleMessage(Message msg) {
                final ChartSweepView sweep = (ChartSweepView) msg.obj;
                updateVertAxisBounds(sweep);
                updateEstimateVisible();

                // we keep dispatching repeating updates until sweep is dropped
                sendUpdateAxisDelayed(sweep, true);
            }
        };
    }

    @Override
    protected void onFinishInflate() {
        super.onFinishInflate();

        mGrid = (ChartGridView) findViewById(R.id.grid);
        mSeries = (ChartNetworkSeriesView) findViewById(R.id.series);
        mDetailSeries = (ChartNetworkSeriesView) findViewById(R.id.detail_series);
        mDetailSeries.setVisibility(View.GONE);

        mSweepLimit = (ChartSweepView) findViewById(R.id.sweep_limit);
        mSweepWarning = (ChartSweepView) findViewById(R.id.sweep_warning);

        // prevent sweeps from crossing each other
        mSweepWarning.setValidRangeDynamic(null, mSweepLimit);
        mSweepLimit.setValidRangeDynamic(mSweepWarning, null);

        // mark neighbors for checking touch events against
        mSweepLimit.setNeighbors(mSweepWarning);
        mSweepWarning.setNeighbors(mSweepLimit);

        mSweepWarning.addOnSweepListener(mVertListener);
        mSweepLimit.addOnSweepListener(mVertListener);

        mSweepWarning.setDragInterval(5 * MB_IN_BYTES);
        mSweepLimit.setDragInterval(5 * MB_IN_BYTES);

        // tell everyone about our axis
        mGrid.init(mHoriz, mVert);
        mSeries.init(mHoriz, mVert);
        mDetailSeries.init(mHoriz, mVert);
        mSweepWarning.init(mVert);
        mSweepLimit.init(mVert);

        setActivated(false);
    }

    public void setListener(DataUsageChartListener listener) {
        mListener = listener;
    }

    public void bindNetworkStats(NetworkStatsHistory stats) {
        mSeries.bindNetworkStats(stats);
        mHistory = stats;
        updateVertAxisBounds(null);
        updateEstimateVisible();
        updatePrimaryRange();
        requestLayout();
    }

    public void bindDetailNetworkStats(NetworkStatsHistory stats) {
        mDetailSeries.bindNetworkStats(stats);
        mDetailSeries.setVisibility(stats != null ? View.VISIBLE : View.GONE);
        if (mHistory != null) {
            mDetailSeries.setEndTime(mHistory.getEnd());
        }
        updateVertAxisBounds(null);
        updateEstimateVisible();
        updatePrimaryRange();
        requestLayout();
    }

    public void bindNetworkPolicy(NetworkPolicy policy) {
        final long warningBytes, limitBytes;

        if (policy == null) {
            warningBytes = NetworkPolicy.LIMIT_DISABLED;
            limitBytes = NetworkPolicy.LIMIT_DISABLED;
        } else {
            warningBytes = policy.warningBytes;
            limitBytes = policy.limitBytes;
        }

        if (limitBytes != NetworkPolicy.LIMIT_DISABLED) {
            mSweepLimit.setVisibility(View.VISIBLE);
            mSweepLimit.setEnabled(true);
            mSweepLimit.setValue(limitBytes);
        } else {
            mSweepLimit.setVisibility(View.INVISIBLE);
            mSweepLimit.setEnabled(false);
            mSweepLimit.setValue(-1);
        }

        if (warningBytes != NetworkPolicy.WARNING_DISABLED) {
            mSweepWarning.setVisibility(View.VISIBLE);
            mSweepWarning.setValue(warningBytes);
        } else {
            mSweepWarning.setVisibility(View.INVISIBLE);
            mSweepWarning.setValue(-1);
        }

        updateVertAxisBounds(null);
        requestLayout();
        invalidate();
    }

    /**
     * Update {@link #mVert} to both show data from {@link NetworkStatsHistory}
     * and controls from {@link NetworkPolicy}.
     */
    private void updateVertAxisBounds(ChartSweepView activeSweep) {
        final long max = mVertMax;

        long newMax = 0;
        if (activeSweep != null) {
            final int adjustAxis = activeSweep.shouldAdjustAxis();
            if (adjustAxis > 0) {
                // hovering around upper edge, grow axis
                newMax = max * 11 / 10;
            } else if (adjustAxis < 0) {
                // hovering around lower edge, shrink axis
                newMax = max * 9 / 10;
            } else {
                newMax = max;
            }
        }

        // always show known data and policy lines
        final long maxSweep = Math.max(mSweepWarning.getValue(), mSweepLimit.getValue());
        final long maxSeries = Math.max(mSeries.getMaxVisible(), mDetailSeries.getMaxVisible());
        final long maxVisible = Math.max(maxSeries, maxSweep) * 12 / 10;
        final long maxDefault = Math.max(maxVisible, 50 * MB_IN_BYTES);
        newMax = Math.max(maxDefault, newMax);

        // only invalidate when vertMax actually changed
        if (newMax != mVertMax) {
            mVertMax = newMax;

            final boolean changed = mVert.setBounds(0L, newMax);
            mSweepWarning.setValidRange(0L, newMax);
            mSweepLimit.setValidRange(0L, newMax);

            if (changed) {
                mSeries.invalidatePath();
                mDetailSeries.invalidatePath();
            }

            mGrid.invalidate();

            // since we just changed axis, make sweep recalculate its value
            if (activeSweep != null) {
                activeSweep.updateValueFromPosition();
            }

            // layout other sweeps to match changed axis
            // TODO: find cleaner way of doing this, such as requesting full
            // layout and making activeSweep discard its tracking MotionEvent.
            if (mSweepLimit != activeSweep) {
                layoutSweep(mSweepLimit);
            }
            if (mSweepWarning != activeSweep) {
                layoutSweep(mSweepWarning);
            }
        }
    }

    /**
     * Control {@link ChartNetworkSeriesView#setEstimateVisible(boolean)} based
     * on how close estimate comes to {@link #mSweepWarning}.
     */
    private void updateEstimateVisible() {
        final long maxEstimate = mSeries.getMaxEstimate();

        // show estimate when near warning/limit
        long interestLine = Long.MAX_VALUE;
        if (mSweepWarning.isEnabled()) {
            interestLine = mSweepWarning.getValue();
        } else if (mSweepLimit.isEnabled()) {
            interestLine = mSweepLimit.getValue();
        }

        if (interestLine < 0) {
            interestLine = Long.MAX_VALUE;
        }

        final boolean estimateVisible = (maxEstimate >= interestLine * 7 / 10);
        mSeries.setEstimateVisible(estimateVisible);
    }

    private void sendUpdateAxisDelayed(ChartSweepView sweep, boolean force) {
        if (force || !mHandler.hasMessages(MSG_UPDATE_AXIS, sweep)) {
            mHandler.sendMessageDelayed(
                    mHandler.obtainMessage(MSG_UPDATE_AXIS, sweep), DELAY_MILLIS);
        }
    }

    private void clearUpdateAxisDelayed(ChartSweepView sweep) {
        mHandler.removeMessages(MSG_UPDATE_AXIS, sweep);
    }

    private OnSweepListener mVertListener = new OnSweepListener() {
        @Override
        public void onSweep(ChartSweepView sweep, boolean sweepDone) {
            if (sweepDone) {
                clearUpdateAxisDelayed(sweep);
                updateEstimateVisible();

                if (sweep == mSweepWarning && mListener != null) {
                    mListener.onWarningChanged();
                } else if (sweep == mSweepLimit && mListener != null) {
                    mListener.onLimitChanged();
                }
            } else {
                // while moving, kick off delayed grow/shrink axis updates
                sendUpdateAxisDelayed(sweep, false);
            }
        }

        @Override
        public void requestEdit(ChartSweepView sweep) {
            if (sweep == mSweepWarning && mListener != null) {
                mListener.requestWarningEdit();
            } else if (sweep == mSweepLimit && mListener != null) {
                mListener.requestLimitEdit();
            }
        }
    };

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        if (isActivated()) return false;
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN: {
                return true;
            }
            case MotionEvent.ACTION_UP: {
                setActivated(true);
                return true;
            }
            default: {
                return false;
            }
        }
    }

    public long getInspectStart() {
        return mInspectStart;
    }

    public long getInspectEnd() {
        return mInspectEnd;
    }

    public long getWarningBytes() {
        return mSweepWarning.getLabelValue();
    }

    public long getLimitBytes() {
        return mSweepLimit.getLabelValue();
    }

    /**
     * Set the exact time range that should be displayed, updating how
     * {@link ChartNetworkSeriesView} paints. Moves inspection ranges to be the
     * last "week" of available data, without triggering listener events.
     */
    public void setVisibleRange(long visibleStart, long visibleEnd) {
        final boolean changed = mHoriz.setBounds(visibleStart, visibleEnd);
        mGrid.setBounds(visibleStart, visibleEnd);
        mSeries.setBounds(visibleStart, visibleEnd);
        mDetailSeries.setBounds(visibleStart, visibleEnd);

        mInspectStart = visibleStart;
        mInspectEnd = visibleEnd;

        requestLayout();
        if (changed) {
            mSeries.invalidatePath();
            mDetailSeries.invalidatePath();
        }

        updateVertAxisBounds(null);
        updateEstimateVisible();
        updatePrimaryRange();
    }

    private void updatePrimaryRange() {
        // prefer showing primary range on detail series, when available
        if (mDetailSeries.getVisibility() == View.VISIBLE) {
            mSeries.setSecondary(true);
        } else {
            mSeries.setSecondary(false);
        }
    }

    public static class TimeAxis implements ChartAxis {
        private static final int FIRST_DAY_OF_WEEK = Calendar.getInstance().getFirstDayOfWeek() - 1;

        private long mMin;
        private long mMax;
        private float mSize;

        public TimeAxis() {
            final long currentTime = System.currentTimeMillis();
            setBounds(currentTime - DateUtils.DAY_IN_MILLIS * 30, currentTime);
        }

        @Override
        public int hashCode() {
            return Objects.hash(mMin, mMax, mSize);
        }

        @Override
        public boolean setBounds(long min, long max) {
            if (mMin != min || mMax != max) {
                mMin = min;
                mMax = max;
                return true;
            } else {
                return false;
            }
        }

        @Override
        public boolean setSize(float size) {
            if (mSize != size) {
                mSize = size;
                return true;
            } else {
                return false;
            }
        }

        @Override
        public float convertToPoint(long value) {
            return (mSize * (value - mMin)) / (mMax - mMin);
        }

        @Override
        public long convertToValue(float point) {
            return (long) (mMin + ((point * (mMax - mMin)) / mSize));
        }

        @Override
        public long buildLabel(Resources res, SpannableStringBuilder builder, long value) {
            // TODO: convert to better string
            builder.replace(0, builder.length(), Long.toString(value));
            return value;
        }

        @Override
        public float[] getTickPoints() {
            final float[] ticks = new float[32];
            int i = 0;

            // tick mark for first day of each week
            final Time time = new Time();
            time.set(mMax);
            time.monthDay -= time.weekDay - FIRST_DAY_OF_WEEK;
            time.hour = time.minute = time.second = 0;

            time.normalize(true);
            long timeMillis = time.toMillis(true);
            while (timeMillis > mMin) {
                if (timeMillis <= mMax) {
                    ticks[i++] = convertToPoint(timeMillis);
                }
                time.monthDay -= 7;
                time.normalize(true);
                timeMillis = time.toMillis(true);
            }

            return Arrays.copyOf(ticks, i);
        }

        @Override
        public int shouldAdjustAxis(long value) {
            // time axis never adjusts
            return 0;
        }
    }

    public static class DataAxis implements ChartAxis {
        private long mMin;
        private long mMax;
        private float mSize;

        private static final boolean LOG_SCALE = false;

        @Override
        public int hashCode() {
            return Objects.hash(mMin, mMax, mSize);
        }

        @Override
        public boolean setBounds(long min, long max) {
            if (mMin != min || mMax != max) {
                mMin = min;
                mMax = max;
                return true;
            } else {
                return false;
            }
        }

        @Override
        public boolean setSize(float size) {
            if (mSize != size) {
                mSize = size;
                return true;
            } else {
                return false;
            }
        }

        @Override
        public float convertToPoint(long value) {
            if (LOG_SCALE) {
                // derived polynomial fit to make lower values more visible
                final double normalized = ((double) value - mMin) / (mMax - mMin);
                final double fraction = Math.pow(10,
                        0.36884343106175121463 * Math.log10(normalized) + -0.04328199452018252624);
                return (float) (fraction * mSize);
            } else {
                return (mSize * (value - mMin)) / (mMax - mMin);
            }
        }

        @Override
        public long convertToValue(float point) {
            if (LOG_SCALE) {
                final double normalized = point / mSize;
                final double fraction = 1.3102228476089056629
                        * Math.pow(normalized, 2.7111774693164631640);
                return (long) (mMin + (fraction * (mMax - mMin)));
            } else {
                return (long) (mMin + ((point * (mMax - mMin)) / mSize));
            }
        }

        private static final Object sSpanSize = new Object();
        private static final Object sSpanUnit = new Object();

        @Override
        public long buildLabel(Resources res, SpannableStringBuilder builder, long value) {
            value = MathUtils.constrain(value, 0, TrafficStats.TB_IN_BYTES);
            final BytesResult result = Formatter.formatBytes(res, value,
                    Formatter.FLAG_SHORTER | Formatter.FLAG_CALCULATE_ROUNDED);
            setText(builder, sSpanSize, result.value, "^1");
            setText(builder, sSpanUnit, result.units, "^2");
            return result.roundedBytes;
        }

        @Override
        public float[] getTickPoints() {
            final long range = mMax - mMin;

            // target about 16 ticks on screen, rounded to nearest power of 2
            final long tickJump = roundUpToPowerOfTwo(range / 16);
            final int tickCount = (int) (range / tickJump);
            final float[] tickPoints = new float[tickCount];
            long value = mMin;
            for (int i = 0; i < tickPoints.length; i++) {
                tickPoints[i] = convertToPoint(value);
                value += tickJump;
            }

            return tickPoints;
        }

        @Override
        public int shouldAdjustAxis(long value) {
            final float point = convertToPoint(value);
            if (point < mSize * 0.1) {
                return -1;
            } else if (point > mSize * 0.85) {
                return 1;
            } else {
                return 0;
            }
        }
    }

    private static void setText(
            SpannableStringBuilder builder, Object key, CharSequence text, String bootstrap) {
        int start = builder.getSpanStart(key);
        int end = builder.getSpanEnd(key);
        if (start == -1) {
            start = TextUtils.indexOf(builder, bootstrap);
            end = start + bootstrap.length();
            builder.setSpan(key, start, end, Spannable.SPAN_INCLUSIVE_INCLUSIVE);
        }
        builder.replace(start, end, text);
    }

    private static long roundUpToPowerOfTwo(long i) {
        // NOTE: borrowed from Hashtable.roundUpToPowerOfTwo()

        i--; // If input is a power of two, shift its high-order bit right

        // "Smear" the high-order bit all the way to the right
        i |= i >>>  1;
        i |= i >>>  2;
        i |= i >>>  4;
        i |= i >>>  8;
        i |= i >>> 16;
        i |= i >>> 32;

        i++;

        return i > 0 ? i : Long.MAX_VALUE;
    }
}