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