| /* |
| * 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 static android.net.TrafficStats.MB_IN_BYTES; |
| |
| 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; |
| |
| /** |
| * 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) { |
| if (policy == null) { |
| mSweepLimit.setVisibility(View.INVISIBLE); |
| mSweepLimit.setValue(-1); |
| mSweepWarning.setVisibility(View.INVISIBLE); |
| mSweepWarning.setValue(-1); |
| return; |
| } |
| |
| if (policy.limitBytes != NetworkPolicy.LIMIT_DISABLED) { |
| mSweepLimit.setVisibility(View.VISIBLE); |
| mSweepLimit.setEnabled(true); |
| mSweepLimit.setValue(policy.limitBytes); |
| } else { |
| mSweepLimit.setVisibility(View.INVISIBLE); |
| mSweepLimit.setEnabled(false); |
| mSweepLimit.setValue(-1); |
| } |
| |
| if (policy.warningBytes != NetworkPolicy.WARNING_DISABLED) { |
| mSweepWarning.setVisibility(View.VISIBLE); |
| mSweepWarning.setValue(policy.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; |
| } |
| } |