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