607 lines
19 KiB
Java
607 lines
19 KiB
Java
/*
|
|
* 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;
|
|
}
|
|
}
|