From 55d18a57e45e11f657346cbaa7fd454f92775229 Mon Sep 17 00:00:00 2001 From: Jeff Sharkey Date: Sat, 27 Aug 2011 17:09:43 -0700 Subject: Data usage UI fixes; sweeps, combined history. Fix sweep z-order so that limit are always above inspection range, and draw shadows behind sweep labels. Narrower margins for sweeps with labels; push labels to keep from overlapping. Generous touch targets on sweeps, and delegate touches to neighboring sweep if nearer. Refresh sweep layout during axis zoom, and don't allow zoom below default minimum. Let inspection sweeps move beyond valid data ranges. Draw less-frequent tick marks when working with large axis ranges. Remove Wi-Fi policies but continue showing historical data. Write NetworkPolicy if modified during read, and snapshot when async write requested. Handle combined UID histories for "Android OS." Bug: 5191421, 5092579, 5225988, 5221101, 5221065, 5221005, 5150906, 5058025 Change-Id: Id51652e8a10bb90e1345f7a8af01bd70cb8ac677 --- .../settings/widget/ChartDataUsageView.java | 569 +++++++++++++++++++++ .../android/settings/widget/ChartSweepView.java | 112 +++- src/com/android/settings/widget/ChartView.java | 54 +- .../settings/widget/DataUsageChartView.java | 542 -------------------- 4 files changed, 707 insertions(+), 570 deletions(-) create mode 100644 src/com/android/settings/widget/ChartDataUsageView.java delete mode 100644 src/com/android/settings/widget/DataUsageChartView.java (limited to 'src/com/android/settings/widget') diff --git a/src/com/android/settings/widget/ChartDataUsageView.java b/src/com/android/settings/widget/ChartDataUsageView.java new file mode 100644 index 0000000..9554368 --- /dev/null +++ b/src/com/android/settings/widget/ChartDataUsageView.java @@ -0,0 +1,569 @@ +/* + * 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.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.util.AttributeSet; +import android.view.Gravity; +import android.view.MotionEvent; +import android.view.View; + +import com.android.settings.R; +import com.android.settings.widget.ChartSweepView.OnSweepListener; + +/** + * 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 long KB_IN_BYTES = 1024; + private static final long MB_IN_BYTES = KB_IN_BYTES * 1024; + private static final long GB_IN_BYTES = MB_IN_BYTES * 1024; + + private static final int MSG_UPDATE_AXIS = 100; + private static final long DELAY_MILLIS = 250; + + private static final boolean LIMIT_SWEEPS_TO_VALID_DATA = false; + + private ChartGridView mGrid; + private ChartNetworkSeriesView mSeries; + private ChartNetworkSeriesView mDetailSeries; + + private NetworkStatsHistory mHistory; + + private ChartSweepView mSweepLeft; + private ChartSweepView mSweepRight; + private ChartSweepView mSweepWarning; + private ChartSweepView mSweepLimit; + + private Handler mHandler; + + /** Current maximum value of {@link #mVert}. */ + private long mVertMax; + + public interface DataUsageChartListener { + public void onInspectRangeChanged(); + public void onWarningChanged(); + public void onLimitChanged(); + } + + 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); + + mSweepLeft = (ChartSweepView) findViewById(R.id.sweep_left); + mSweepRight = (ChartSweepView) findViewById(R.id.sweep_right); + mSweepLimit = (ChartSweepView) findViewById(R.id.sweep_limit); + mSweepWarning = (ChartSweepView) findViewById(R.id.sweep_warning); + + // prevent sweeps from crossing each other + mSweepLeft.setValidRangeDynamic(null, mSweepRight); + mSweepRight.setValidRangeDynamic(mSweepLeft, null); + mSweepWarning.setValidRangeDynamic(null, mSweepLimit); + mSweepLimit.setValidRangeDynamic(mSweepWarning, null); + + mSweepLeft.addOnSweepListener(mHorizListener); + mSweepRight.addOnSweepListener(mHorizListener); + mSweepWarning.addOnSweepListener(mVertListener); + mSweepLimit.addOnSweepListener(mVertListener); + + // tell everyone about our axis + mGrid.init(mHoriz, mVert); + mSeries.init(mHoriz, mVert); + mDetailSeries.init(mHoriz, mVert); + mSweepLeft.init(mHoriz); + mSweepRight.init(mHoriz); + 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.VISIBLE); + 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 maxVisible = Math.max(mSeries.getMaxVisible(), maxSweep) * 12 / 10; + final long maxDefault = Math.max(maxVisible, 2 * GB_IN_BYTES); + newMax = Math.max(maxDefault, newMax); + + // only invalidate when vertMax actually changed + if (newMax != mVertMax) { + mVertMax = newMax; + + mVert.setBounds(0L, newMax); + mSweepWarning.setValidRange(0L, newMax); + mSweepLimit.setValidRange(0L, newMax); + + mSeries.generatePath(); + mDetailSeries.generatePath(); + + 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(); + } + + final boolean estimateVisible = (maxEstimate >= interestLine * 7 / 10); + mSeries.setEstimateVisible(estimateVisible); + } + + private OnSweepListener mHorizListener = new OnSweepListener() { + public void onSweep(ChartSweepView sweep, boolean sweepDone) { + updatePrimaryRange(); + + // update detail list only when done sweeping + if (sweepDone && mListener != null) { + mListener.onInspectRangeChanged(); + } + } + }; + + 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() { + 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 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 mSweepLeft.getValue(); + } + + public long getInspectEnd() { + return mSweepRight.getValue(); + } + + public long getWarningBytes() { + return mSweepWarning.getValue(); + } + + public long getLimitBytes() { + return mSweepLimit.getValue(); + } + + private long getStatsStart() { + return mHistory != null ? mHistory.getStart() : Long.MIN_VALUE; + } + + private long getStatsEnd() { + return mHistory != null ? mHistory.getEnd() : Long.MAX_VALUE; + } + + /** + * 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) { + mHoriz.setBounds(visibleStart, visibleEnd); + mGrid.setBounds(visibleStart, visibleEnd); + + final long validStart = Math.max(visibleStart, getStatsStart()); + final long validEnd = Math.min(visibleEnd, getStatsEnd()); + + if (LIMIT_SWEEPS_TO_VALID_DATA) { + // prevent time sweeps from leaving valid data + mSweepLeft.setValidRange(validStart, validEnd); + mSweepRight.setValidRange(validStart, validEnd); + } else { + mSweepLeft.setValidRange(visibleStart, visibleEnd); + mSweepRight.setValidRange(visibleStart, visibleEnd); + } + + // default sweeps to last week of data + final long halfRange = (visibleEnd + visibleStart) / 2; + final long sweepMax = validEnd; + final long sweepMin = Math.max(visibleStart, (sweepMax - DateUtils.WEEK_IN_MILLIS)); + + mSweepLeft.setValue(sweepMin); + mSweepRight.setValue(sweepMax); + + requestLayout(); + mSeries.generatePath(); + + updateVertAxisBounds(null); + updateEstimateVisible(); + updatePrimaryRange(); + } + + private void updatePrimaryRange() { + final long left = mSweepLeft.getValue(); + final long right = mSweepRight.getValue(); + + // prefer showing primary range on detail series, when available + if (mDetailSeries.getVisibility() == View.VISIBLE) { + mDetailSeries.setPrimaryRange(left, right); + mSeries.setPrimaryRange(0, 0); + } else { + mSeries.setPrimaryRange(left, right); + } + } + + public static class TimeAxis implements ChartAxis { + private static final long TICK_INTERVAL = DateUtils.DAY_IN_MILLIS * 7; + + private long mMin; + private long mMax; + private float mSize; + + public TimeAxis() { + final long currentTime = System.currentTimeMillis(); + setBounds(currentTime - DateUtils.DAY_IN_MILLIS * 30, currentTime); + } + + /** {@inheritDoc} */ + public void setBounds(long min, long max) { + mMin = min; + mMax = max; + } + + /** {@inheritDoc} */ + public void setSize(float size) { + this.mSize = size; + } + + /** {@inheritDoc} */ + public float convertToPoint(long value) { + return (mSize * (value - mMin)) / (mMax - mMin); + } + + /** {@inheritDoc} */ + public long convertToValue(float point) { + return (long) (mMin + ((point * (mMax - mMin)) / mSize)); + } + + /** {@inheritDoc} */ + public void buildLabel(Resources res, SpannableStringBuilder builder, long value) { + // TODO: convert to better string + builder.replace(0, builder.length(), Long.toString(value)); + } + + /** {@inheritDoc} */ + public float[] getTickPoints() { + // tick mark for every week + final int tickCount = (int) ((mMax - mMin) / TICK_INTERVAL); + final float[] tickPoints = new float[tickCount]; + for (int i = 0; i < tickCount; i++) { + tickPoints[i] = convertToPoint(mMax - (TICK_INTERVAL * (i + 1))); + } + return tickPoints; + } + + /** {@inheritDoc} */ + 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; + + /** {@inheritDoc} */ + public void setBounds(long min, long max) { + mMin = min; + mMax = max; + } + + /** {@inheritDoc} */ + public void setSize(float size) { + mSize = size; + } + + /** {@inheritDoc} */ + public float convertToPoint(long value) { + // 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); + } + + /** {@inheritDoc} */ + public long convertToValue(float point) { + final double normalized = point / mSize; + final double fraction = 1.3102228476089056629 + * Math.pow(normalized, 2.7111774693164631640); + return (long) (mMin + (fraction * (mMax - mMin))); + } + + private static final Object sSpanSize = new Object(); + private static final Object sSpanUnit = new Object(); + + /** {@inheritDoc} */ + public void buildLabel(Resources res, SpannableStringBuilder builder, long value) { + + float result = value; + final CharSequence unit; + if (result <= 100 * MB_IN_BYTES) { + unit = res.getText(com.android.internal.R.string.megabyteShort); + result /= MB_IN_BYTES; + } else { + unit = res.getText(com.android.internal.R.string.gigabyteShort); + result /= GB_IN_BYTES; + } + + final CharSequence size; + if (result < 10) { + size = String.format("%.1f", result); + } else { + size = String.format("%.0f", result); + } + + final int[] sizeBounds = findOrCreateSpan(builder, sSpanSize, "^1"); + builder.replace(sizeBounds[0], sizeBounds[1], size); + final int[] unitBounds = findOrCreateSpan(builder, sSpanUnit, "^2"); + builder.replace(unitBounds[0], unitBounds[1], unit); + } + + /** {@inheritDoc} */ + public float[] getTickPoints() { + final long range = mMax - mMin; + final long tickJump; + if (range < 6 * GB_IN_BYTES) { + tickJump = 256 * MB_IN_BYTES; + } else if (range < 12 * GB_IN_BYTES) { + tickJump = 512 * MB_IN_BYTES; + } else { + tickJump = 1 * GB_IN_BYTES; + } + + 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; + } + + /** {@inheritDoc} */ + 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 int[] findOrCreateSpan( + SpannableStringBuilder builder, Object key, CharSequence 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); + } + return new int[] { start, end }; + } + +} diff --git a/src/com/android/settings/widget/ChartSweepView.java b/src/com/android/settings/widget/ChartSweepView.java index 81aeb84..0d91a76 100644 --- a/src/com/android/settings/widget/ChartSweepView.java +++ b/src/com/android/settings/widget/ChartSweepView.java @@ -21,6 +21,7 @@ import android.content.res.TypedArray; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; +import android.graphics.Paint.Style; import android.graphics.Point; import android.graphics.Rect; import android.graphics.drawable.Drawable; @@ -42,8 +43,14 @@ import com.google.common.base.Preconditions; */ public class ChartSweepView extends View { + private static final boolean DRAW_OUTLINE = false; + private Drawable mSweep; private Rect mSweepPadding = new Rect(); + + /** Offset of content inside this view. */ + private Point mContentOffset = new Point(); + /** Offset of {@link #mSweep} inside this view. */ private Point mSweepOffset = new Point(); private Rect mMargins = new Rect(); @@ -66,6 +73,8 @@ public class ChartSweepView extends View { private ChartSweepView mValidAfterDynamic; private ChartSweepView mValidBeforeDynamic; + private Paint mOutlinePaint = new Paint(); + public static final int HORIZONTAL = 0; public static final int VERTICAL = 1; @@ -98,6 +107,10 @@ public class ChartSweepView extends View { setLabelTemplate(a.getResourceId(R.styleable.ChartSweepView_labelTemplate, 0)); setLabelColor(a.getColor(R.styleable.ChartSweepView_labelColor, Color.BLUE)); + mOutlinePaint.setColor(Color.RED); + mOutlinePaint.setStrokeWidth(1f); + mOutlinePaint.setStyle(Style.STROKE); + a.recycle(); setWillNotDraw(false); @@ -123,11 +136,11 @@ public class ChartSweepView extends View { if (mFollowAxis == VERTICAL) { final float targetHeight = mSweep.getIntrinsicHeight() - mSweepPadding.top - mSweepPadding.bottom; - return mSweepPadding.top + (targetHeight / 2); + return mSweepPadding.top + (targetHeight / 2) + mSweepOffset.y; } else { final float targetWidth = mSweep.getIntrinsicWidth() - mSweepPadding.left - mSweepPadding.right; - return mSweepPadding.left + (targetWidth / 2); + return mSweepPadding.left + (targetWidth / 2) + mSweepOffset.x; } } @@ -195,6 +208,7 @@ public class ChartSweepView extends View { paint.density = getResources().getDisplayMetrics().density; paint.setCompatibilityScaling(getResources().getCompatibilityInfo().applicationScale); paint.setColor(mLabelColor); + paint.setShadowLayer(4 * paint.density, 0, 0, Color.BLACK); mLabelTemplate = new SpannableStringBuilder(template); mLabelLayout = new DynamicLayout( @@ -283,6 +297,26 @@ public class ChartSweepView extends View { mValidBeforeDynamic = validBefore; } + /** + * Test if given {@link MotionEvent} is closer to another + * {@link ChartSweepView} compared to ourselves. + */ + public boolean isTouchCloserTo(MotionEvent eventInParent, ChartSweepView another) { + if (another == null) return false; + + if (mFollowAxis == HORIZONTAL) { + final float selfDist = Math.abs(eventInParent.getX() - (getX() + getTargetInset())); + final float anotherDist = Math.abs( + eventInParent.getX() - (another.getX() + another.getTargetInset())); + return anotherDist < selfDist; + } else { + final float selfDist = Math.abs(eventInParent.getY() - (getY() + getTargetInset())); + final float anotherDist = Math.abs( + eventInParent.getY() - (another.getY() + another.getTargetInset())); + return anotherDist < selfDist; + } + } + @Override public boolean onTouchEvent(MotionEvent event) { if (!isEnabled()) return false; @@ -294,9 +328,18 @@ public class ChartSweepView extends View { // only start tracking when in sweet spot final boolean accept; if (mFollowAxis == VERTICAL) { - accept = event.getX() > getWidth() - (mSweepPadding.right * 2); + accept = event.getX() > getWidth() - (mSweepPadding.right * 3); } else { - accept = event.getY() > getHeight() - (mSweepPadding.bottom * 2); + accept = event.getY() > getHeight() - (mSweepPadding.bottom * 3); + } + + final MotionEvent eventInParent = event.copy(); + eventInParent.offsetLocation(getLeft(), getTop()); + + // ignore event when closer to a neighbor + if (isTouchCloserTo(eventInParent, mValidAfterDynamic) + || isTouchCloserTo(eventInParent, mValidBeforeDynamic)) { + return false; } if (accept) { @@ -460,6 +503,7 @@ public class ChartSweepView extends View { final int templateHeight = mLabelLayout.getHeight(); mSweepOffset.x = 0; + mSweepOffset.y = 0; mSweepOffset.y = (int) ((templateHeight / 2) - getTargetInset()); setMeasuredDimension(mSweep.getIntrinsicWidth(), Math.max(sweepHeight, templateHeight)); @@ -485,6 +529,23 @@ public class ChartSweepView extends View { mMargins.bottom = mSweepPadding.bottom; } + mContentOffset.x = 0; + mContentOffset.y = 0; + + // make touch target area larger + if (mFollowAxis == HORIZONTAL) { + final int widthBefore = getMeasuredWidth(); + final int widthAfter = widthBefore * 3; + setMeasuredDimension(widthAfter, getMeasuredHeight()); + mContentOffset.offset((widthAfter - widthBefore) / 2, 0); + } else { + final int heightBefore = getMeasuredHeight(); + final int heightAfter = heightBefore * 3; + setMeasuredDimension(getMeasuredWidth(), heightAfter); + mContentOffset.offset(0, (heightAfter - heightBefore) / 2); + } + + mSweepOffset.offset(mContentOffset.x, mContentOffset.y); mMargins.offset(-mSweepOffset.x, -mSweepOffset.y); } @@ -493,9 +554,43 @@ public class ChartSweepView extends View { final int width = getWidth(); final int height = getHeight(); + if (DRAW_OUTLINE) { + canvas.drawRect(0, 0, width, height, mOutlinePaint); + } + + // when overlapping with neighbor, split difference and push label + float margin; + float labelOffset = 0; + if (mFollowAxis == VERTICAL) { + if (mValidAfterDynamic != null) { + margin = getLabelTop(mValidAfterDynamic) - getLabelBottom(this); + if (margin < 0) { + labelOffset = margin / 2; + } + } else if (mValidBeforeDynamic != null) { + margin = getLabelTop(this) - getLabelBottom(mValidBeforeDynamic); + if (margin < 0) { + labelOffset = -margin / 2; + } + } + } else { + // TODO: implement horizontal labels + } + + // when offsetting label, neighbor probably needs to offset too + if (labelOffset != 0) { + if (mValidAfterDynamic != null) mValidAfterDynamic.invalidate(); + if (mValidBeforeDynamic != null) mValidBeforeDynamic.invalidate(); + } + final int labelSize; if (isEnabled() && mLabelLayout != null) { - mLabelLayout.draw(canvas); + final int count = canvas.save(); + { + canvas.translate(mContentOffset.x, mContentOffset.y + labelOffset); + mLabelLayout.draw(canvas); + } + canvas.restoreToCount(count); labelSize = mLabelSize; } else { labelSize = 0; @@ -512,4 +607,11 @@ public class ChartSweepView extends View { mSweep.draw(canvas); } + public static float getLabelTop(ChartSweepView view) { + return view.getY() + view.mContentOffset.y; + } + + public static float getLabelBottom(ChartSweepView view) { + return getLabelTop(view) + view.mLabelLayout.getHeight(); + } } diff --git a/src/com/android/settings/widget/ChartView.java b/src/com/android/settings/widget/ChartView.java index e3a658a..f410d57 100644 --- a/src/com/android/settings/widget/ChartView.java +++ b/src/com/android/settings/widget/ChartView.java @@ -36,8 +36,6 @@ import com.android.settings.R; * and screen coordinates. */ public class ChartView extends FrameLayout { - private static final String TAG = "ChartView"; - // TODO: extend something that supports two-dimensional scrolling private static final int SWEEP_GRAVITY = Gravity.TOP | Gravity.LEFT; @@ -122,29 +120,39 @@ public class ChartView extends FrameLayout { child.layout(childRect.left, childRect.top, childRect.right, childRect.bottom); } else if (child instanceof ChartSweepView) { - // sweep is always placed along specific dimension - final ChartSweepView sweep = (ChartSweepView) child; - final Rect sweepMargins = sweep.getMargins(); - - if (sweep.getFollowAxis() == ChartSweepView.VERTICAL) { - parentRect.top += sweepMargins.top + (int) sweep.getPoint(); - parentRect.bottom = parentRect.top; - parentRect.left += sweepMargins.left; - parentRect.right += sweepMargins.right; - Gravity.apply(SWEEP_GRAVITY, parentRect.width(), child.getMeasuredHeight(), - parentRect, childRect); - - } else { - parentRect.left += sweepMargins.left + (int) sweep.getPoint(); - parentRect.right = parentRect.left; - parentRect.top += sweepMargins.top; - parentRect.bottom += sweepMargins.bottom; - Gravity.apply(SWEEP_GRAVITY, child.getMeasuredWidth(), parentRect.height(), - parentRect, childRect); - } + layoutSweep((ChartSweepView) child, parentRect, childRect); + child.layout(childRect.left, childRect.top, childRect.right, childRect.bottom); } + } + } + + protected void layoutSweep(ChartSweepView sweep) { + final Rect parentRect = new Rect(mContent); + final Rect childRect = new Rect(); + + layoutSweep(sweep, parentRect, childRect); + sweep.layout(childRect.left, childRect.top, childRect.right, childRect.bottom); + } - child.layout(childRect.left, childRect.top, childRect.right, childRect.bottom); + protected void layoutSweep(ChartSweepView sweep, Rect parentRect, Rect childRect) { + final Rect sweepMargins = sweep.getMargins(); + + // sweep is always placed along specific dimension + if (sweep.getFollowAxis() == ChartSweepView.VERTICAL) { + parentRect.top += sweepMargins.top + (int) sweep.getPoint(); + parentRect.bottom = parentRect.top; + parentRect.left += sweepMargins.left; + parentRect.right += sweepMargins.right; + Gravity.apply(SWEEP_GRAVITY, parentRect.width(), sweep.getMeasuredHeight(), + parentRect, childRect); + + } else { + parentRect.left += sweepMargins.left + (int) sweep.getPoint(); + parentRect.right = parentRect.left; + parentRect.top += sweepMargins.top; + parentRect.bottom += sweepMargins.bottom; + Gravity.apply(SWEEP_GRAVITY, sweep.getMeasuredWidth(), parentRect.height(), + parentRect, childRect); } } diff --git a/src/com/android/settings/widget/DataUsageChartView.java b/src/com/android/settings/widget/DataUsageChartView.java deleted file mode 100644 index f6ae5a0..0000000 --- a/src/com/android/settings/widget/DataUsageChartView.java +++ /dev/null @@ -1,542 +0,0 @@ -/* - * 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.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.util.AttributeSet; -import android.view.MotionEvent; -import android.view.View; - -import com.android.settings.R; -import com.android.settings.widget.ChartSweepView.OnSweepListener; - -/** - * Specific {@link ChartView} that displays {@link ChartNetworkSeriesView} along - * with {@link ChartSweepView} for inspection ranges and warning/limits. - */ -public class DataUsageChartView extends ChartView { - - private static final long KB_IN_BYTES = 1024; - private static final long MB_IN_BYTES = KB_IN_BYTES * 1024; - private static final long GB_IN_BYTES = MB_IN_BYTES * 1024; - - 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 mSweepLeft; - private ChartSweepView mSweepRight; - private ChartSweepView mSweepWarning; - private ChartSweepView mSweepLimit; - - private Handler mHandler; - - /** Current maximum value of {@link #mVert}. */ - private long mVertMax; - - public interface DataUsageChartListener { - public void onInspectRangeChanged(); - public void onWarningChanged(); - public void onLimitChanged(); - } - - private DataUsageChartListener mListener; - - public DataUsageChartView(Context context) { - this(context, null, 0); - } - - public DataUsageChartView(Context context, AttributeSet attrs) { - this(context, attrs, 0); - } - - public DataUsageChartView(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); - - mSweepLeft = (ChartSweepView) findViewById(R.id.sweep_left); - mSweepRight = (ChartSweepView) findViewById(R.id.sweep_right); - mSweepLimit = (ChartSweepView) findViewById(R.id.sweep_limit); - mSweepWarning = (ChartSweepView) findViewById(R.id.sweep_warning); - - // prevent sweeps from crossing each other - mSweepLeft.setValidRangeDynamic(null, mSweepRight); - mSweepRight.setValidRangeDynamic(mSweepLeft, null); - mSweepWarning.setValidRangeDynamic(null, mSweepLimit); - mSweepLimit.setValidRangeDynamic(mSweepWarning, null); - - mSweepLeft.addOnSweepListener(mHorizListener); - mSweepRight.addOnSweepListener(mHorizListener); - mSweepWarning.addOnSweepListener(mVertListener); - mSweepLimit.addOnSweepListener(mVertListener); - - // tell everyone about our axis - mGrid.init(mHoriz, mVert); - mSeries.init(mHoriz, mVert); - mDetailSeries.init(mHoriz, mVert); - mSweepLeft.init(mHoriz); - mSweepRight.init(mHoriz); - 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.VISIBLE); - 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(); - } - - /** - * Update {@link #mVert} to both show data from {@link NetworkStatsHistory} - * and controls from {@link NetworkPolicy}. - */ - private void updateVertAxisBounds(ChartSweepView activeSweep) { - final long max = mVertMax; - final long newMax; - 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; - } - - } else { - // try showing all known data and policy - final long maxSweep = Math.max(mSweepWarning.getValue(), mSweepLimit.getValue()); - final long maxVisible = Math.max(mSeries.getMaxVisible(), maxSweep) * 12 / 10; - newMax = Math.max(maxVisible, 2 * GB_IN_BYTES); - } - - // only invalidate when vertMax actually changed - if (newMax != mVertMax) { - mVertMax = newMax; - - mVert.setBounds(0L, newMax); - mSweepWarning.setValidRange(0L, newMax); - mSweepLimit.setValidRange(0L, newMax); - - mSeries.generatePath(); - mDetailSeries.generatePath(); - - mGrid.invalidate(); - - // since we just changed axis, make sweep recalculate its value - if (activeSweep != null) { - activeSweep.updateValueFromPosition(); - } - } - } - - /** - * 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(); - } - - final boolean estimateVisible = (maxEstimate >= interestLine * 7 / 10); - mSeries.setEstimateVisible(estimateVisible); - } - - private OnSweepListener mHorizListener = new OnSweepListener() { - public void onSweep(ChartSweepView sweep, boolean sweepDone) { - updatePrimaryRange(); - - // update detail list only when done sweeping - if (sweepDone && mListener != null) { - mListener.onInspectRangeChanged(); - } - } - }; - - 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() { - 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 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 mSweepLeft.getValue(); - } - - public long getInspectEnd() { - return mSweepRight.getValue(); - } - - public long getWarningBytes() { - return mSweepWarning.getValue(); - } - - public long getLimitBytes() { - return mSweepLimit.getValue(); - } - - private long getStatsStart() { - return mHistory != null ? mHistory.getStart() : Long.MIN_VALUE; - } - - private long getStatsEnd() { - return mHistory != null ? mHistory.getEnd() : Long.MAX_VALUE; - } - - /** - * 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) { - mHoriz.setBounds(visibleStart, visibleEnd); - mGrid.setBounds(visibleStart, visibleEnd); - - final long validStart = Math.max(visibleStart, getStatsStart()); - final long validEnd = Math.min(visibleEnd, getStatsEnd()); - - // prevent time sweeps from leaving valid data - mSweepLeft.setValidRange(validStart, validEnd); - mSweepRight.setValidRange(validStart, validEnd); - - // default sweeps to last week of data - final long halfRange = (visibleEnd + visibleStart) / 2; - final long sweepMax = validEnd; - final long sweepMin = Math.max(visibleStart, (sweepMax - DateUtils.WEEK_IN_MILLIS)); - - mSweepLeft.setValue(sweepMin); - mSweepRight.setValue(sweepMax); - - requestLayout(); - mSeries.generatePath(); - - updateVertAxisBounds(null); - updateEstimateVisible(); - updatePrimaryRange(); - } - - private void updatePrimaryRange() { - final long left = mSweepLeft.getValue(); - final long right = mSweepRight.getValue(); - - // prefer showing primary range on detail series, when available - if (mDetailSeries.getVisibility() == View.VISIBLE) { - mDetailSeries.setPrimaryRange(left, right); - mSeries.setPrimaryRange(0, 0); - } else { - mSeries.setPrimaryRange(left, right); - } - } - - public static class TimeAxis implements ChartAxis { - private static final long TICK_INTERVAL = DateUtils.DAY_IN_MILLIS * 7; - - private long mMin; - private long mMax; - private float mSize; - - public TimeAxis() { - final long currentTime = System.currentTimeMillis(); - setBounds(currentTime - DateUtils.DAY_IN_MILLIS * 30, currentTime); - } - - /** {@inheritDoc} */ - public void setBounds(long min, long max) { - mMin = min; - mMax = max; - } - - /** {@inheritDoc} */ - public void setSize(float size) { - this.mSize = size; - } - - /** {@inheritDoc} */ - public float convertToPoint(long value) { - return (mSize * (value - mMin)) / (mMax - mMin); - } - - /** {@inheritDoc} */ - public long convertToValue(float point) { - return (long) (mMin + ((point * (mMax - mMin)) / mSize)); - } - - /** {@inheritDoc} */ - public void buildLabel(Resources res, SpannableStringBuilder builder, long value) { - // TODO: convert to better string - builder.replace(0, builder.length(), Long.toString(value)); - } - - /** {@inheritDoc} */ - public float[] getTickPoints() { - // tick mark for every week - final int tickCount = (int) ((mMax - mMin) / TICK_INTERVAL); - final float[] tickPoints = new float[tickCount]; - for (int i = 0; i < tickCount; i++) { - tickPoints[i] = convertToPoint(mMax - (TICK_INTERVAL * i)); - } - return tickPoints; - } - - /** {@inheritDoc} */ - 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; - - /** {@inheritDoc} */ - public void setBounds(long min, long max) { - mMin = min; - mMax = max; - } - - /** {@inheritDoc} */ - public void setSize(float size) { - mSize = size; - } - - /** {@inheritDoc} */ - public float convertToPoint(long value) { - // 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); - } - - /** {@inheritDoc} */ - public long convertToValue(float point) { - final double normalized = point / mSize; - final double fraction = 1.3102228476089056629 - * Math.pow(normalized, 2.7111774693164631640); - return (long) (mMin + (fraction * (mMax - mMin))); - } - - private static final Object sSpanSize = new Object(); - private static final Object sSpanUnit = new Object(); - - /** {@inheritDoc} */ - public void buildLabel(Resources res, SpannableStringBuilder builder, long value) { - - float result = value; - final CharSequence unit; - if (result <= 100 * MB_IN_BYTES) { - unit = res.getText(com.android.internal.R.string.megabyteShort); - result /= MB_IN_BYTES; - } else { - unit = res.getText(com.android.internal.R.string.gigabyteShort); - result /= GB_IN_BYTES; - } - - final CharSequence size; - if (result < 10) { - size = String.format("%.1f", result); - } else { - size = String.format("%.0f", result); - } - - final int[] sizeBounds = findOrCreateSpan(builder, sSpanSize, "^1"); - builder.replace(sizeBounds[0], sizeBounds[1], size); - final int[] unitBounds = findOrCreateSpan(builder, sSpanUnit, "^2"); - builder.replace(unitBounds[0], unitBounds[1], unit); - } - - /** {@inheritDoc} */ - public float[] getTickPoints() { - final long range = mMax - mMin; - final long tickJump = 256 * MB_IN_BYTES; - - 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; - } - - /** {@inheritDoc} */ - 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 int[] findOrCreateSpan( - SpannableStringBuilder builder, Object key, CharSequence 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); - } - return new int[] { start, end }; - } - -} -- cgit v1.1