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