1 /* 2 * Copyright (C) 2024 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.internal.widget; 18 19 import android.content.pm.ActivityInfo.Config; 20 import android.content.res.Resources; 21 import android.content.res.Resources.Theme; 22 import android.content.res.TypedArray; 23 import android.graphics.Canvas; 24 import android.graphics.Color; 25 import android.graphics.ColorFilter; 26 import android.graphics.Paint; 27 import android.graphics.PixelFormat; 28 import android.graphics.Rect; 29 import android.graphics.RectF; 30 import android.graphics.drawable.Drawable; 31 import android.util.AttributeSet; 32 import android.util.DisplayMetrics; 33 import android.util.Log; 34 35 import androidx.annotation.ColorInt; 36 import androidx.annotation.NonNull; 37 import androidx.annotation.Nullable; 38 39 import com.android.internal.R; 40 41 import org.xmlpull.v1.XmlPullParser; 42 import org.xmlpull.v1.XmlPullParserException; 43 44 import java.io.IOException; 45 import java.util.ArrayList; 46 import java.util.Arrays; 47 import java.util.List; 48 import java.util.Objects; 49 50 /** 51 * This is used by NotificationProgressBar for displaying a custom background. It composes of 52 * segments, which have non-zero length varying drawing width, and points, which have zero length 53 * and fixed size for drawing. 54 * 55 * @see DrawableSegment 56 * @see DrawablePoint 57 */ 58 public final class NotificationProgressDrawable extends Drawable { 59 private static final String TAG = "NotifProgressDrawable"; 60 61 @Nullable 62 private BoundsChangeListener mBoundsChangeListener = null; 63 64 private State mState; 65 private boolean mMutated; 66 67 private final ArrayList<DrawablePart> mParts = new ArrayList<>(); 68 69 private final RectF mSegRectF = new RectF(); 70 private final RectF mPointRectF = new RectF(); 71 72 private final Paint mFillPaint = new Paint(); 73 74 { 75 mFillPaint.setStyle(Paint.Style.FILL); 76 } 77 78 private @ColorInt int mEndDotColor = Color.TRANSPARENT; 79 80 private int mAlpha; 81 NotificationProgressDrawable()82 public NotificationProgressDrawable() { 83 this(new State(), null); 84 } 85 86 /** 87 * Returns the radius for the points. 88 */ getPointRadius()89 public float getPointRadius() { 90 return mState.mPointRadius; 91 } 92 93 /** 94 * Set the segments and points that constitute the drawable. 95 */ setParts(List<DrawablePart> parts)96 public void setParts(List<DrawablePart> parts) { 97 mParts.clear(); 98 mParts.addAll(parts); 99 100 invalidateSelf(); 101 } 102 103 /** 104 * Set the segments and points that constitute the drawable. 105 */ setParts(@onNull DrawablePart... parts)106 public void setParts(@NonNull DrawablePart... parts) { 107 setParts(Arrays.asList(parts)); 108 } 109 110 /** 111 * Update the color of the end dot. If TRANSPARENT, the dot is not drawn. 112 */ updateEndDotColor(@olorInt int endDotColor)113 public void updateEndDotColor(@ColorInt int endDotColor) { 114 if (mEndDotColor != endDotColor) { 115 mEndDotColor = endDotColor; 116 invalidateSelf(); 117 } 118 } 119 120 @Override draw(@onNull Canvas canvas)121 public void draw(@NonNull Canvas canvas) { 122 final float pointRadius = mState.mPointRadius; 123 final float left = (float) getBounds().left; 124 final float centerY = (float) getBounds().centerY(); 125 126 final int numParts = mParts.size(); 127 final float pointTop = Math.round(centerY - pointRadius); 128 final float pointBottom = Math.round(centerY + pointRadius); 129 for (int iPart = 0; iPart < numParts; iPart++) { 130 final DrawablePart part = mParts.get(iPart); 131 final float start = left + part.mStart; 132 final float end = left + part.mEnd; 133 if (part instanceof DrawableSegment segment) { 134 // No space left to draw the segment 135 if (start > end) continue; 136 137 final float radiusY = segment.mFaded ? mState.mFadedSegmentHeight / 2F 138 : mState.mSegmentHeight / 2F; 139 final float cornerRadius = mState.mSegmentCornerRadius; 140 141 mFillPaint.setColor(segment.mColor); 142 143 mSegRectF.set(Math.round(start), Math.round(centerY - radiusY), Math.round(end), 144 Math.round(centerY + radiusY)); 145 canvas.drawRoundRect(mSegRectF, cornerRadius, cornerRadius, mFillPaint); 146 } else if (part instanceof DrawablePoint point) { 147 // TODO: b/367804171 - actually use a vector asset for the default point 148 // rather than drawing it as a box? 149 mPointRectF.set(Math.round(start), pointTop, Math.round(end), pointBottom); 150 final float inset = mState.mPointRectInset; 151 final float cornerRadius = mState.mPointRectCornerRadius; 152 mPointRectF.inset(inset, inset); 153 154 mFillPaint.setColor(point.mColor); 155 156 canvas.drawRoundRect(mPointRectF, cornerRadius, cornerRadius, mFillPaint); 157 } 158 } 159 160 if (mEndDotColor != Color.TRANSPARENT) { 161 final float right = (float) getBounds().right; 162 final float dotRadius = mState.mFadedSegmentHeight / 2F; 163 mFillPaint.setColor(mEndDotColor); 164 // Use drawRoundRect instead of drawCircle to ensure alignment with the segment below. 165 mSegRectF.set( 166 Math.round(right - mState.mFadedSegmentHeight), Math.round(centerY - dotRadius), 167 Math.round(right), Math.round(centerY + dotRadius)); 168 canvas.drawRoundRect(mSegRectF, mState.mSegmentCornerRadius, 169 mState.mSegmentCornerRadius, mFillPaint); 170 } 171 } 172 173 @Override getChangingConfigurations()174 public @Config int getChangingConfigurations() { 175 return super.getChangingConfigurations() | mState.getChangingConfigurations(); 176 } 177 178 @Override setAlpha(int alpha)179 public void setAlpha(int alpha) { 180 if (mAlpha != alpha) { 181 mAlpha = alpha; 182 invalidateSelf(); 183 } 184 } 185 186 @Override getAlpha()187 public int getAlpha() { 188 return mAlpha; 189 } 190 191 @Override setColorFilter(@ullable ColorFilter colorFilter)192 public void setColorFilter(@Nullable ColorFilter colorFilter) { 193 // NO-OP 194 } 195 196 @Override getOpacity()197 public int getOpacity() { 198 // This method is deprecated. Hence we return UNKNOWN. 199 return PixelFormat.UNKNOWN; 200 } 201 setBoundsChangeListener(BoundsChangeListener listener)202 public void setBoundsChangeListener(BoundsChangeListener listener) { 203 mBoundsChangeListener = listener; 204 } 205 206 @Override onBoundsChange(Rect bounds)207 protected void onBoundsChange(Rect bounds) { 208 super.onBoundsChange(bounds); 209 210 if (mBoundsChangeListener != null) { 211 mBoundsChangeListener.onDrawableBoundsChanged(); 212 } 213 } 214 215 @Override inflate(@onNull Resources r, @NonNull XmlPullParser parser, @NonNull AttributeSet attrs, @Nullable Resources.Theme theme)216 public void inflate(@NonNull Resources r, @NonNull XmlPullParser parser, 217 @NonNull AttributeSet attrs, @Nullable Resources.Theme theme) 218 throws XmlPullParserException, IOException { 219 super.inflate(r, parser, attrs, theme); 220 221 mState.setDensity(resolveDensity(r, 0)); 222 223 inflateChildElements(r, parser, attrs, theme); 224 225 updateLocalState(); 226 } 227 228 @Override applyTheme(@onNull Theme t)229 public void applyTheme(@NonNull Theme t) { 230 super.applyTheme(t); 231 232 final State state = mState; 233 if (state == null) { 234 return; 235 } 236 237 state.setDensity(resolveDensity(t.getResources(), 0)); 238 239 applyThemeChildElements(t); 240 241 updateLocalState(); 242 } 243 244 @Override canApplyTheme()245 public boolean canApplyTheme() { 246 return (mState.canApplyTheme()) || super.canApplyTheme(); 247 } 248 inflateChildElements(Resources r, XmlPullParser parser, AttributeSet attrs, Theme theme)249 private void inflateChildElements(Resources r, XmlPullParser parser, AttributeSet attrs, 250 Theme theme) throws XmlPullParserException, IOException { 251 TypedArray a; 252 int type; 253 254 final int innerDepth = parser.getDepth() + 1; 255 int depth; 256 while ((type = parser.next()) != XmlPullParser.END_DOCUMENT 257 && ((depth = parser.getDepth()) >= innerDepth 258 || type != XmlPullParser.END_TAG)) { 259 if (type != XmlPullParser.START_TAG) { 260 continue; 261 } 262 263 if (depth > innerDepth) { 264 continue; 265 } 266 267 String name = parser.getName(); 268 269 if (name.equals("segments")) { 270 a = obtainAttributes(r, theme, attrs, 271 R.styleable.NotificationProgressDrawableSegments); 272 updateSegmentsFromTypedArray(a); 273 a.recycle(); 274 } else if (name.equals("points")) { 275 a = obtainAttributes(r, theme, attrs, 276 R.styleable.NotificationProgressDrawablePoints); 277 updatePointsFromTypedArray(a); 278 a.recycle(); 279 } else { 280 Log.w(TAG, "Bad element under NotificationProgressDrawable: " + name); 281 } 282 } 283 } 284 applyThemeChildElements(Theme t)285 private void applyThemeChildElements(Theme t) { 286 final State state = mState; 287 288 if (state.mThemeAttrsSegments != null) { 289 final TypedArray a = t.resolveAttributes( 290 state.mThemeAttrsSegments, R.styleable.NotificationProgressDrawableSegments); 291 updateSegmentsFromTypedArray(a); 292 a.recycle(); 293 } 294 295 if (state.mThemeAttrsPoints != null) { 296 final TypedArray a = t.resolveAttributes( 297 state.mThemeAttrsPoints, R.styleable.NotificationProgressDrawablePoints); 298 updatePointsFromTypedArray(a); 299 a.recycle(); 300 } 301 } 302 updateSegmentsFromTypedArray(TypedArray a)303 private void updateSegmentsFromTypedArray(TypedArray a) { 304 final State state = mState; 305 306 // Account for any configuration changes. 307 state.mChangingConfigurations |= a.getChangingConfigurations(); 308 309 // Extract the theme attributes, if any. 310 state.mThemeAttrsSegments = a.extractThemeAttrs(); 311 312 state.mSegmentHeight = a.getDimension( 313 R.styleable.NotificationProgressDrawableSegments_height, state.mSegmentHeight); 314 state.mFadedSegmentHeight = a.getDimension( 315 R.styleable.NotificationProgressDrawableSegments_fadedHeight, 316 state.mFadedSegmentHeight); 317 state.mSegmentCornerRadius = a.getDimension( 318 R.styleable.NotificationProgressDrawableSegments_cornerRadius, 319 state.mSegmentCornerRadius); 320 } 321 updatePointsFromTypedArray(TypedArray a)322 private void updatePointsFromTypedArray(TypedArray a) { 323 final State state = mState; 324 325 // Account for any configuration changes. 326 state.mChangingConfigurations |= a.getChangingConfigurations(); 327 328 // Extract the theme attributes, if any. 329 state.mThemeAttrsPoints = a.extractThemeAttrs(); 330 331 state.mPointRadius = a.getDimension(R.styleable.NotificationProgressDrawablePoints_radius, 332 state.mPointRadius); 333 state.mPointRectInset = a.getDimension(R.styleable.NotificationProgressDrawablePoints_inset, 334 state.mPointRectInset); 335 state.mPointRectCornerRadius = a.getDimension( 336 R.styleable.NotificationProgressDrawablePoints_cornerRadius, 337 state.mPointRectCornerRadius); 338 } 339 resolveDensity(@ullable Resources r, int parentDensity)340 static int resolveDensity(@Nullable Resources r, int parentDensity) { 341 final int densityDpi = r == null ? parentDensity : r.getDisplayMetrics().densityDpi; 342 return densityDpi == 0 ? DisplayMetrics.DENSITY_DEFAULT : densityDpi; 343 } 344 345 /** 346 * Scales a floating-point pixel value from the source density to the 347 * target density. 348 */ scaleFromDensity(float pixels, int sourceDensity, int targetDensity)349 private static float scaleFromDensity(float pixels, int sourceDensity, int targetDensity) { 350 return pixels * targetDensity / sourceDensity; 351 } 352 353 /** 354 * Scales a pixel value from the source density to the target density. 355 * <p> 356 * Optionally, when {@code isSize} is true, handles the resulting pixel value as a size, 357 * which is rounded to the closest positive integer. 358 * <p> 359 * Note: Iteratively applying density changes could result in drift of the pixel values due 360 * to rounding, especially for paddings which are truncated. Therefore it should be avoided. 361 * This isn't an issue for the notifications because the inflation pipeline reinflates 362 * notification views on density change. 363 */ scaleFromDensity( int pixels, int sourceDensity, int targetDensity, boolean isSize)364 private static int scaleFromDensity( 365 int pixels, int sourceDensity, int targetDensity, boolean isSize) { 366 if (pixels == 0 || sourceDensity == targetDensity) { 367 return pixels; 368 } 369 370 final float result = pixels * targetDensity / (float) sourceDensity; 371 if (!isSize) { 372 return (int) result; 373 } 374 375 final int rounded = Math.round(result); 376 if (rounded != 0) { 377 return rounded; 378 } else if (pixels > 0) { 379 return 1; 380 } else { 381 return -1; 382 } 383 } 384 385 /** 386 * Listener to receive updates about drawable bounds changing 387 */ 388 public interface BoundsChangeListener { 389 /** Called when bounds have changed */ onDrawableBoundsChanged()390 void onDrawableBoundsChanged(); 391 } 392 393 /** 394 * A part of the progress drawable, which is either a {@link DrawableSegment} with non-zero 395 * length and varying drawing width, or a {@link DrawablePoint} with zero length and fixed size 396 * for drawing. 397 */ 398 public abstract static class DrawablePart { 399 // TODO: b/372908709 - maybe rename start/end to left/right, to be consistent with the 400 // bounds rect. 401 /** Start position for drawing (in pixels) */ 402 protected float mStart; 403 /** End position for drawing (in pixels) */ 404 protected float mEnd; 405 /** Drawing color. */ 406 @ColorInt protected final int mColor; 407 DrawablePart(float start, float end, @ColorInt int color)408 protected DrawablePart(float start, float end, @ColorInt int color) { 409 mStart = start; 410 mEnd = end; 411 mColor = color; 412 } 413 getStart()414 public float getStart() { 415 return this.mStart; 416 } 417 setStart(float start)418 public void setStart(float start) { 419 mStart = start; 420 } 421 getEnd()422 public float getEnd() { 423 return this.mEnd; 424 } 425 setEnd(float end)426 public void setEnd(float end) { 427 mEnd = end; 428 } 429 430 /** Returns the calculated drawing width of the part */ getWidth()431 public float getWidth() { 432 return mEnd - mStart; 433 } 434 getColor()435 public int getColor() { 436 return this.mColor; 437 } 438 439 // Needed for unit tests 440 @Override equals(@ullable Object other)441 public boolean equals(@Nullable Object other) { 442 if (this == other) return true; 443 444 if (other == null || getClass() != other.getClass()) return false; 445 446 DrawablePart that = (DrawablePart) other; 447 if (Float.compare(this.mStart, that.mStart) != 0) return false; 448 if (Float.compare(this.mEnd, that.mEnd) != 0) return false; 449 return this.mColor == that.mColor; 450 } 451 452 @Override hashCode()453 public int hashCode() { 454 return Objects.hash(mStart, mEnd, mColor); 455 } 456 } 457 458 /** 459 * A segment is a part of the progress bar with non-zero length. For example, it can 460 * represent a portion in a navigation journey with certain traffic condition. 461 * <p> 462 * The start and end positions for drawing a segment are assumed to have been adjusted for 463 * the Points and gaps neighboring the segment. 464 * </p> 465 */ 466 public static final class DrawableSegment extends DrawablePart { 467 /** 468 * Whether the segment is faded or not. 469 * <p> 470 * Faded segments and non-faded segments are drawn with different heights. 471 * </p> 472 */ 473 private final boolean mFaded; 474 DrawableSegment(float start, float end, int color)475 public DrawableSegment(float start, float end, int color) { 476 this(start, end, color, false); 477 } 478 DrawableSegment(float start, float end, int color, boolean faded)479 public DrawableSegment(float start, float end, int color, boolean faded) { 480 super(start, end, color); 481 mFaded = faded; 482 } 483 484 @Override toString()485 public String toString() { 486 return "Segment(start=" + this.mStart + ", end=" + this.mEnd + ", color=" + this.mColor 487 + ", faded=" + this.mFaded + ')'; 488 } 489 490 // Needed for unit tests. 491 @Override equals(@ullable Object other)492 public boolean equals(@Nullable Object other) { 493 if (!super.equals(other)) return false; 494 495 DrawableSegment that = (DrawableSegment) other; 496 return this.mFaded == that.mFaded; 497 } 498 499 @Override hashCode()500 public int hashCode() { 501 return Objects.hash(super.hashCode(), mFaded); 502 } 503 } 504 505 /** 506 * A point is a part of the progress bar with zero length. Points are designated points within a 507 * progress bar to visualize distinct stages or milestones. For example, a stop in a multi-stop 508 * ride-share journey. 509 */ 510 public static final class DrawablePoint extends DrawablePart { DrawablePoint(float start, float end, int color)511 public DrawablePoint(float start, float end, int color) { 512 super(start, end, color); 513 } 514 515 @Override toString()516 public String toString() { 517 return "Point(start=" + this.mStart + ", end=" + this.mEnd + ", color=" + this.mColor 518 + ")"; 519 } 520 } 521 522 @Override mutate()523 public Drawable mutate() { 524 if (!mMutated && super.mutate() == this) { 525 mState = new State(mState, null); 526 updateLocalState(); 527 mMutated = true; 528 } 529 return this; 530 } 531 532 @Override clearMutated()533 public void clearMutated() { 534 super.clearMutated(); 535 mMutated = false; 536 } 537 538 static final class State extends ConstantState { 539 @Config 540 int mChangingConfigurations; 541 float mSegmentHeight; 542 float mFadedSegmentHeight; 543 float mSegmentCornerRadius; 544 // how big the point icon will be, halved 545 float mPointRadius; 546 float mPointRectInset; 547 float mPointRectCornerRadius; 548 549 int[] mThemeAttrs; 550 int[] mThemeAttrsSegments; 551 int[] mThemeAttrsPoints; 552 553 int mDensity = DisplayMetrics.DENSITY_DEFAULT; 554 State()555 State() { 556 } 557 State(@onNull State orig, @Nullable Resources res)558 State(@NonNull State orig, @Nullable Resources res) { 559 mChangingConfigurations = orig.mChangingConfigurations; 560 mSegmentHeight = orig.mSegmentHeight; 561 mFadedSegmentHeight = orig.mFadedSegmentHeight; 562 mSegmentCornerRadius = orig.mSegmentCornerRadius; 563 mPointRadius = orig.mPointRadius; 564 mPointRectInset = orig.mPointRectInset; 565 mPointRectCornerRadius = orig.mPointRectCornerRadius; 566 567 mThemeAttrs = orig.mThemeAttrs; 568 mThemeAttrsSegments = orig.mThemeAttrsSegments; 569 mThemeAttrsPoints = orig.mThemeAttrsPoints; 570 571 mDensity = resolveDensity(res, orig.mDensity); 572 if (orig.mDensity != mDensity) { 573 applyDensityScaling(orig.mDensity, mDensity); 574 } 575 } 576 applyDensityScaling(int sourceDensity, int targetDensity)577 private void applyDensityScaling(int sourceDensity, int targetDensity) { 578 if (mSegmentHeight > 0) { 579 mSegmentHeight = scaleFromDensity( 580 mSegmentHeight, sourceDensity, targetDensity); 581 } 582 if (mFadedSegmentHeight > 0) { 583 mFadedSegmentHeight = scaleFromDensity( 584 mFadedSegmentHeight, sourceDensity, targetDensity); 585 } 586 if (mSegmentCornerRadius > 0) { 587 mSegmentCornerRadius = scaleFromDensity( 588 mSegmentCornerRadius, sourceDensity, targetDensity); 589 } 590 if (mPointRadius > 0) { 591 mPointRadius = scaleFromDensity( 592 mPointRadius, sourceDensity, targetDensity); 593 } 594 if (mPointRectInset > 0) { 595 mPointRectInset = scaleFromDensity( 596 mPointRectInset, sourceDensity, targetDensity); 597 } 598 if (mPointRectCornerRadius > 0) { 599 mPointRectCornerRadius = scaleFromDensity( 600 mPointRectCornerRadius, sourceDensity, targetDensity); 601 } 602 } 603 604 @NonNull 605 @Override newDrawable()606 public Drawable newDrawable() { 607 return new NotificationProgressDrawable(this, null); 608 } 609 610 @Override newDrawable(@ullable Resources res)611 public Drawable newDrawable(@Nullable Resources res) { 612 // If this drawable is being created for a different density, 613 // just create a new constant state and call it a day. 614 final State state; 615 final int density = resolveDensity(res, mDensity); 616 if (density != mDensity) { 617 state = new State(this, res); 618 } else { 619 state = this; 620 } 621 622 return new NotificationProgressDrawable(state, res); 623 } 624 625 @Override getChangingConfigurations()626 public int getChangingConfigurations() { 627 return mChangingConfigurations; 628 } 629 630 @Override canApplyTheme()631 public boolean canApplyTheme() { 632 return mThemeAttrs != null || mThemeAttrsSegments != null || mThemeAttrsPoints != null 633 || super.canApplyTheme(); 634 } 635 setDensity(int targetDensity)636 public void setDensity(int targetDensity) { 637 if (mDensity != targetDensity) { 638 final int sourceDensity = mDensity; 639 mDensity = targetDensity; 640 641 applyDensityScaling(sourceDensity, targetDensity); 642 } 643 } 644 } 645 646 @Override getConstantState()647 public ConstantState getConstantState() { 648 mState.mChangingConfigurations = getChangingConfigurations(); 649 return mState; 650 } 651 652 /** 653 * Creates a new themed NotificationProgressDrawable based on the specified constant state. 654 * <p> 655 * The resulting drawable is guaranteed to have a new constant state. 656 * 657 * @param state Constant state from which the drawable inherits 658 */ NotificationProgressDrawable(@onNull State state, @Nullable Resources res)659 private NotificationProgressDrawable(@NonNull State state, @Nullable Resources res) { 660 mState = state; 661 662 updateLocalState(); 663 } 664 updateLocalState()665 private void updateLocalState() { 666 // NO-OP 667 } 668 } 669