1 /* 2 * Copyright (C) 2020 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 package com.android.wm.shell.bubbles; 17 18 import static android.app.ActivityTaskManager.INVALID_TASK_ID; 19 import static android.os.AsyncTask.Status.FINISHED; 20 21 import static com.android.internal.annotations.VisibleForTesting.Visibility.PRIVATE; 22 23 import android.annotation.DimenRes; 24 import android.annotation.Hide; 25 import android.annotation.NonNull; 26 import android.annotation.Nullable; 27 import android.app.Notification; 28 import android.app.PendingIntent; 29 import android.app.Person; 30 import android.content.Context; 31 import android.content.Intent; 32 import android.content.LocusId; 33 import android.content.pm.ApplicationInfo; 34 import android.content.pm.PackageManager; 35 import android.content.pm.ShortcutInfo; 36 import android.content.res.Resources; 37 import android.graphics.Bitmap; 38 import android.graphics.Path; 39 import android.graphics.drawable.Drawable; 40 import android.graphics.drawable.Icon; 41 import android.os.Parcelable; 42 import android.os.UserHandle; 43 import android.provider.Settings; 44 import android.service.notification.StatusBarNotification; 45 import android.text.TextUtils; 46 import android.util.Log; 47 48 import com.android.internal.annotations.VisibleForTesting; 49 import com.android.internal.logging.InstanceId; 50 51 import java.io.PrintWriter; 52 import java.util.List; 53 import java.util.Objects; 54 import java.util.concurrent.Executor; 55 56 /** 57 * Encapsulates the data and UI elements of a bubble. 58 */ 59 @VisibleForTesting 60 public class Bubble implements BubbleViewProvider { 61 private static final String TAG = "Bubble"; 62 63 public static final String KEY_APP_BUBBLE = "key_app_bubble"; 64 65 private final String mKey; 66 @Nullable 67 private final String mGroupKey; 68 @Nullable 69 private final LocusId mLocusId; 70 71 private final Executor mMainExecutor; 72 73 private long mLastUpdated; 74 private long mLastAccessed; 75 76 @Nullable 77 private Bubbles.BubbleMetadataFlagListener mBubbleMetadataFlagListener; 78 79 /** Whether the bubble should show a dot for the notification indicating updated content. */ 80 private boolean mShowBubbleUpdateDot = true; 81 82 /** Whether flyout text should be suppressed, regardless of any other flags or state. */ 83 private boolean mSuppressFlyout; 84 85 // Items that are typically loaded later 86 private String mAppName; 87 private ShortcutInfo mShortcutInfo; 88 private String mMetadataShortcutId; 89 private BadgedImageView mIconView; 90 private BubbleExpandedView mExpandedView; 91 92 private BubbleViewInfoTask mInflationTask; 93 private boolean mInflateSynchronously; 94 private boolean mPendingIntentCanceled; 95 private boolean mIsImportantConversation; 96 97 /** 98 * Presentational info about the flyout. 99 */ 100 public static class FlyoutMessage { 101 @Nullable public Icon senderIcon; 102 @Nullable public Drawable senderAvatar; 103 @Nullable public CharSequence senderName; 104 @Nullable public CharSequence message; 105 @Nullable public boolean isGroupChat; 106 } 107 108 private FlyoutMessage mFlyoutMessage; 109 // The developer provided image for the bubble 110 private Bitmap mBubbleBitmap; 111 // The app badge for the bubble 112 private Bitmap mBadgeBitmap; 113 // App badge without any markings for important conversations 114 private Bitmap mRawBadgeBitmap; 115 private int mDotColor; 116 private Path mDotPath; 117 private int mFlags; 118 119 @NonNull 120 private UserHandle mUser; 121 @NonNull 122 private String mPackageName; 123 @Nullable 124 private String mTitle; 125 @Nullable 126 private Icon mIcon; 127 private boolean mIsBubble; 128 private boolean mIsTextChanged; 129 private boolean mIsDismissable; 130 private boolean mShouldSuppressNotificationDot; 131 private boolean mShouldSuppressNotificationList; 132 private boolean mShouldSuppressPeek; 133 private int mDesiredHeight; 134 @DimenRes 135 private int mDesiredHeightResId; 136 private int mTaskId; 137 138 /** for logging **/ 139 @Nullable 140 private InstanceId mInstanceId; 141 @Nullable 142 private String mChannelId; 143 private int mNotificationId; 144 private int mAppUid = -1; 145 146 /** 147 * A bubble is created and can be updated. This intent is updated until the user first 148 * expands the bubble. Once the user has expanded the contents, we ignore the intent updates 149 * to prevent restarting the intent & possibly altering UI state in the activity in front of 150 * the user. 151 * 152 * Once the bubble is overflowed, the activity is finished and updates to the 153 * notification are respected. Typically an update to an overflowed bubble would result in 154 * that bubble being added back to the stack anyways. 155 */ 156 @Nullable 157 private PendingIntent mIntent; 158 private boolean mIntentActive; 159 @Nullable 160 private PendingIntent.CancelListener mIntentCancelListener; 161 162 /** 163 * Sent when the bubble & notification are no longer visible to the user (i.e. no 164 * notification in the shade, no bubble in the stack or overflow). 165 */ 166 @Nullable 167 private PendingIntent mDeleteIntent; 168 169 /** 170 * Used only for a special bubble in the stack that has the key {@link #KEY_APP_BUBBLE}. 171 * There can only be one of these bubbles in the stack and this intent will be populated for 172 * that bubble. 173 */ 174 @Nullable 175 private Intent mAppIntent; 176 177 /** 178 * Create a bubble with limited information based on given {@link ShortcutInfo}. 179 * Note: Currently this is only being used when the bubble is persisted to disk. 180 */ 181 @VisibleForTesting(visibility = PRIVATE) Bubble(@onNull final String key, @NonNull final ShortcutInfo shortcutInfo, final int desiredHeight, final int desiredHeightResId, @Nullable final String title, int taskId, @Nullable final String locus, boolean isDismissable, Executor mainExecutor, final Bubbles.BubbleMetadataFlagListener listener)182 public Bubble(@NonNull final String key, @NonNull final ShortcutInfo shortcutInfo, 183 final int desiredHeight, final int desiredHeightResId, @Nullable final String title, 184 int taskId, @Nullable final String locus, boolean isDismissable, Executor mainExecutor, 185 final Bubbles.BubbleMetadataFlagListener listener) { 186 Objects.requireNonNull(key); 187 Objects.requireNonNull(shortcutInfo); 188 mMetadataShortcutId = shortcutInfo.getId(); 189 mShortcutInfo = shortcutInfo; 190 mKey = key; 191 mGroupKey = null; 192 mLocusId = locus != null ? new LocusId(locus) : null; 193 mIsDismissable = isDismissable; 194 mFlags = 0; 195 mUser = shortcutInfo.getUserHandle(); 196 mPackageName = shortcutInfo.getPackage(); 197 mIcon = shortcutInfo.getIcon(); 198 mDesiredHeight = desiredHeight; 199 mDesiredHeightResId = desiredHeightResId; 200 mTitle = title; 201 mShowBubbleUpdateDot = false; 202 mMainExecutor = mainExecutor; 203 mTaskId = taskId; 204 mBubbleMetadataFlagListener = listener; 205 } 206 Bubble(Intent intent, UserHandle user, Executor mainExecutor)207 public Bubble(Intent intent, 208 UserHandle user, 209 Executor mainExecutor) { 210 mKey = KEY_APP_BUBBLE; 211 mGroupKey = null; 212 mLocusId = null; 213 mFlags = 0; 214 mUser = user; 215 mShowBubbleUpdateDot = false; 216 mMainExecutor = mainExecutor; 217 mTaskId = INVALID_TASK_ID; 218 mAppIntent = intent; 219 mDesiredHeight = Integer.MAX_VALUE; 220 mPackageName = intent.getPackage(); 221 } 222 223 @VisibleForTesting(visibility = PRIVATE) Bubble(@onNull final BubbleEntry entry, final Bubbles.BubbleMetadataFlagListener listener, final Bubbles.PendingIntentCanceledListener intentCancelListener, Executor mainExecutor)224 public Bubble(@NonNull final BubbleEntry entry, 225 final Bubbles.BubbleMetadataFlagListener listener, 226 final Bubbles.PendingIntentCanceledListener intentCancelListener, 227 Executor mainExecutor) { 228 mKey = entry.getKey(); 229 mGroupKey = entry.getGroupKey(); 230 mLocusId = entry.getLocusId(); 231 mBubbleMetadataFlagListener = listener; 232 mIntentCancelListener = intent -> { 233 if (mIntent != null) { 234 mIntent.unregisterCancelListener(mIntentCancelListener); 235 } 236 mainExecutor.execute(() -> { 237 intentCancelListener.onPendingIntentCanceled(this); 238 }); 239 }; 240 mMainExecutor = mainExecutor; 241 mTaskId = INVALID_TASK_ID; 242 setEntry(entry); 243 } 244 245 @Override getKey()246 public String getKey() { 247 return mKey; 248 } 249 250 @Hide isDismissable()251 public boolean isDismissable() { 252 return mIsDismissable; 253 } 254 255 /** 256 * @see StatusBarNotification#getGroupKey() 257 * @return the group key for this bubble, if one exists. 258 */ getGroupKey()259 public String getGroupKey() { 260 return mGroupKey; 261 } 262 getLocusId()263 public LocusId getLocusId() { 264 return mLocusId; 265 } 266 getUser()267 public UserHandle getUser() { 268 return mUser; 269 } 270 271 @NonNull getPackageName()272 public String getPackageName() { 273 return mPackageName; 274 } 275 276 @Override getBubbleIcon()277 public Bitmap getBubbleIcon() { 278 return mBubbleBitmap; 279 } 280 281 @Override getAppBadge()282 public Bitmap getAppBadge() { 283 return mBadgeBitmap; 284 } 285 286 @Override getRawAppBadge()287 public Bitmap getRawAppBadge() { 288 return mRawBadgeBitmap; 289 } 290 291 @Override getDotColor()292 public int getDotColor() { 293 return mDotColor; 294 } 295 296 @Override getDotPath()297 public Path getDotPath() { 298 return mDotPath; 299 } 300 301 @Nullable getAppName()302 public String getAppName() { 303 return mAppName; 304 } 305 306 @Nullable getShortcutInfo()307 public ShortcutInfo getShortcutInfo() { 308 return mShortcutInfo; 309 } 310 311 @Nullable 312 @Override getIconView()313 public BadgedImageView getIconView() { 314 return mIconView; 315 } 316 317 @Override 318 @Nullable getExpandedView()319 public BubbleExpandedView getExpandedView() { 320 return mExpandedView; 321 } 322 323 @Nullable getTitle()324 public String getTitle() { 325 return mTitle; 326 } 327 328 /** 329 * @return the ShortcutInfo id if it exists, or the metadata shortcut id otherwise. 330 */ getShortcutId()331 String getShortcutId() { 332 return getShortcutInfo() != null 333 ? getShortcutInfo().getId() 334 : getMetadataShortcutId(); 335 } 336 getMetadataShortcutId()337 String getMetadataShortcutId() { 338 return mMetadataShortcutId; 339 } 340 hasMetadataShortcutId()341 boolean hasMetadataShortcutId() { 342 return (mMetadataShortcutId != null && !mMetadataShortcutId.isEmpty()); 343 } 344 345 /** 346 * Call this to clean up the task for the bubble. Ensure this is always called when done with 347 * the bubble. 348 */ cleanupExpandedView()349 void cleanupExpandedView() { 350 if (mExpandedView != null) { 351 mExpandedView.cleanUpExpandedState(); 352 mExpandedView = null; 353 } 354 if (mIntent != null) { 355 mIntent.unregisterCancelListener(mIntentCancelListener); 356 } 357 mIntentActive = false; 358 } 359 360 /** 361 * Call when all the views should be removed/cleaned up. 362 */ cleanupViews()363 void cleanupViews() { 364 cleanupExpandedView(); 365 mIconView = null; 366 } 367 setPendingIntentCanceled()368 void setPendingIntentCanceled() { 369 mPendingIntentCanceled = true; 370 } 371 getPendingIntentCanceled()372 boolean getPendingIntentCanceled() { 373 return mPendingIntentCanceled; 374 } 375 376 /** 377 * Sets whether to perform inflation on the same thread as the caller. This method should only 378 * be used in tests, not in production. 379 */ 380 @VisibleForTesting setInflateSynchronously(boolean inflateSynchronously)381 void setInflateSynchronously(boolean inflateSynchronously) { 382 mInflateSynchronously = inflateSynchronously; 383 } 384 385 /** 386 * Sets whether this bubble is considered text changed. This method is purely for 387 * testing. 388 */ 389 @VisibleForTesting setTextChangedForTest(boolean textChanged)390 void setTextChangedForTest(boolean textChanged) { 391 mIsTextChanged = textChanged; 392 } 393 394 /** 395 * Starts a task to inflate & load any necessary information to display a bubble. 396 * 397 * @param callback the callback to notify one the bubble is ready to be displayed. 398 * @param context the context for the bubble. 399 * @param controller the bubble controller. 400 * @param stackView the stackView the bubble is eventually added to. 401 * @param iconFactory the icon factory use to create images for the bubble. 402 * @param badgeIconFactory the icon factory to create app badges for the bubble. 403 */ inflate(BubbleViewInfoTask.Callback callback, Context context, BubbleController controller, BubbleStackView stackView, BubbleIconFactory iconFactory, BubbleBadgeIconFactory badgeIconFactory, boolean skipInflation)404 void inflate(BubbleViewInfoTask.Callback callback, 405 Context context, 406 BubbleController controller, 407 BubbleStackView stackView, 408 BubbleIconFactory iconFactory, 409 BubbleBadgeIconFactory badgeIconFactory, 410 boolean skipInflation) { 411 if (isBubbleLoading()) { 412 mInflationTask.cancel(true /* mayInterruptIfRunning */); 413 } 414 mInflationTask = new BubbleViewInfoTask(this, 415 context, 416 controller, 417 stackView, 418 iconFactory, 419 badgeIconFactory, 420 skipInflation, 421 callback, 422 mMainExecutor); 423 if (mInflateSynchronously) { 424 mInflationTask.onPostExecute(mInflationTask.doInBackground()); 425 } else { 426 mInflationTask.execute(); 427 } 428 } 429 isBubbleLoading()430 private boolean isBubbleLoading() { 431 return mInflationTask != null && mInflationTask.getStatus() != FINISHED; 432 } 433 isInflated()434 boolean isInflated() { 435 return mIconView != null && mExpandedView != null; 436 } 437 stopInflation()438 void stopInflation() { 439 if (mInflationTask == null) { 440 return; 441 } 442 mInflationTask.cancel(true /* mayInterruptIfRunning */); 443 } 444 setViewInfo(BubbleViewInfoTask.BubbleViewInfo info)445 void setViewInfo(BubbleViewInfoTask.BubbleViewInfo info) { 446 if (!isInflated()) { 447 mIconView = info.imageView; 448 mExpandedView = info.expandedView; 449 } 450 451 mShortcutInfo = info.shortcutInfo; 452 mAppName = info.appName; 453 if (mTitle == null) { 454 mTitle = mAppName; 455 } 456 mFlyoutMessage = info.flyoutMessage; 457 458 mBadgeBitmap = info.badgeBitmap; 459 mRawBadgeBitmap = info.mRawBadgeBitmap; 460 mBubbleBitmap = info.bubbleBitmap; 461 462 mDotColor = info.dotColor; 463 mDotPath = info.dotPath; 464 465 if (mExpandedView != null) { 466 mExpandedView.update(this /* bubble */); 467 } 468 if (mIconView != null) { 469 mIconView.setRenderedBubble(this /* bubble */); 470 } 471 } 472 473 /** 474 * Set visibility of bubble in the expanded state. 475 * 476 * @param visibility {@code true} if the expanded bubble should be visible on the screen. 477 * 478 * Note that this contents visibility doesn't affect visibility at {@link android.view.View}, 479 * and setting {@code false} actually means rendering the expanded view in transparent. 480 */ 481 @Override setTaskViewVisibility(boolean visibility)482 public void setTaskViewVisibility(boolean visibility) { 483 if (mExpandedView != null) { 484 mExpandedView.setContentVisibility(visibility); 485 } 486 } 487 488 /** 489 * Sets the entry associated with this bubble. 490 */ setEntry(@onNull final BubbleEntry entry)491 void setEntry(@NonNull final BubbleEntry entry) { 492 Objects.requireNonNull(entry); 493 boolean showingDotPreviously = showDot(); 494 mLastUpdated = entry.getStatusBarNotification().getPostTime(); 495 mIsBubble = entry.getStatusBarNotification().getNotification().isBubbleNotification(); 496 mPackageName = entry.getStatusBarNotification().getPackageName(); 497 mUser = entry.getStatusBarNotification().getUser(); 498 mTitle = getTitle(entry); 499 mChannelId = entry.getStatusBarNotification().getNotification().getChannelId(); 500 mNotificationId = entry.getStatusBarNotification().getId(); 501 mAppUid = entry.getStatusBarNotification().getUid(); 502 mInstanceId = entry.getStatusBarNotification().getInstanceId(); 503 mFlyoutMessage = extractFlyoutMessage(entry); 504 if (entry.getRanking() != null) { 505 mShortcutInfo = entry.getRanking().getConversationShortcutInfo(); 506 mIsTextChanged = entry.getRanking().isTextChanged(); 507 if (entry.getRanking().getChannel() != null) { 508 mIsImportantConversation = 509 entry.getRanking().getChannel().isImportantConversation(); 510 } 511 } 512 if (entry.getBubbleMetadata() != null) { 513 mMetadataShortcutId = entry.getBubbleMetadata().getShortcutId(); 514 mFlags = entry.getBubbleMetadata().getFlags(); 515 mDesiredHeight = entry.getBubbleMetadata().getDesiredHeight(); 516 mDesiredHeightResId = entry.getBubbleMetadata().getDesiredHeightResId(); 517 mIcon = entry.getBubbleMetadata().getIcon(); 518 519 if (!mIntentActive || mIntent == null) { 520 if (mIntent != null) { 521 mIntent.unregisterCancelListener(mIntentCancelListener); 522 } 523 mIntent = entry.getBubbleMetadata().getIntent(); 524 if (mIntent != null) { 525 mIntent.registerCancelListener(mIntentCancelListener); 526 } 527 } else if (mIntent != null && entry.getBubbleMetadata().getIntent() == null) { 528 // Was an intent bubble now it's a shortcut bubble... still unregister the listener 529 mIntent.unregisterCancelListener(mIntentCancelListener); 530 mIntentActive = false; 531 mIntent = null; 532 } 533 mDeleteIntent = entry.getBubbleMetadata().getDeleteIntent(); 534 } 535 536 mIsDismissable = entry.isDismissable(); 537 mShouldSuppressNotificationDot = entry.shouldSuppressNotificationDot(); 538 mShouldSuppressNotificationList = entry.shouldSuppressNotificationList(); 539 mShouldSuppressPeek = entry.shouldSuppressPeek(); 540 if (showingDotPreviously != showDot()) { 541 // This will update the UI if needed 542 setShowDot(showDot()); 543 } 544 } 545 546 @Nullable getIcon()547 Icon getIcon() { 548 return mIcon; 549 } 550 isTextChanged()551 boolean isTextChanged() { 552 return mIsTextChanged; 553 } 554 555 /** 556 * @return the last time this bubble was updated or accessed, whichever is most recent. 557 */ getLastActivity()558 long getLastActivity() { 559 return isAppBubble() ? Long.MAX_VALUE : Math.max(mLastUpdated, mLastAccessed); 560 } 561 562 /** 563 * Sets if the intent used for this bubble is currently active (i.e. populating an 564 * expanded view, expanded or not). 565 */ setIntentActive()566 void setIntentActive() { 567 mIntentActive = true; 568 } 569 isIntentActive()570 boolean isIntentActive() { 571 return mIntentActive; 572 } 573 getInstanceId()574 public InstanceId getInstanceId() { 575 return mInstanceId; 576 } 577 578 @Nullable getChannelId()579 public String getChannelId() { 580 return mChannelId; 581 } 582 getNotificationId()583 public int getNotificationId() { 584 return mNotificationId; 585 } 586 587 /** 588 * @return the task id of the task in which bubble contents is drawn. 589 */ 590 @Override getTaskId()591 public int getTaskId() { 592 return mExpandedView != null ? mExpandedView.getTaskId() : mTaskId; 593 } 594 595 /** 596 * Should be invoked whenever a Bubble is accessed (selected while expanded). 597 */ markAsAccessedAt(long lastAccessedMillis)598 void markAsAccessedAt(long lastAccessedMillis) { 599 mLastAccessed = lastAccessedMillis; 600 setSuppressNotification(true); 601 setShowDot(false /* show */); 602 } 603 604 /** 605 * Should be invoked whenever a Bubble is promoted from overflow. 606 */ markUpdatedAt(long lastAccessedMillis)607 void markUpdatedAt(long lastAccessedMillis) { 608 mLastUpdated = lastAccessedMillis; 609 } 610 611 /** 612 * Whether this notification should be shown in the shade. 613 */ showInShade()614 boolean showInShade() { 615 return !shouldSuppressNotification() || !mIsDismissable; 616 } 617 618 /** 619 * Whether this bubble is currently being hidden from the stack. 620 */ isSuppressed()621 boolean isSuppressed() { 622 return (mFlags & Notification.BubbleMetadata.FLAG_SUPPRESS_BUBBLE) != 0; 623 } 624 625 /** 626 * Whether this bubble is able to be suppressed (i.e. has the developer opted into the API to 627 * hide the bubble when in the same content). 628 */ isSuppressable()629 boolean isSuppressable() { 630 return (mFlags & Notification.BubbleMetadata.FLAG_SUPPRESSABLE_BUBBLE) != 0; 631 } 632 633 /** 634 * Whether this notification conversation is important. 635 */ isImportantConversation()636 boolean isImportantConversation() { 637 return mIsImportantConversation; 638 } 639 640 /** 641 * Sets whether this notification should be suppressed in the shade. 642 */ 643 @VisibleForTesting setSuppressNotification(boolean suppressNotification)644 public void setSuppressNotification(boolean suppressNotification) { 645 boolean prevShowInShade = showInShade(); 646 if (suppressNotification) { 647 mFlags |= Notification.BubbleMetadata.FLAG_SUPPRESS_NOTIFICATION; 648 } else { 649 mFlags &= ~Notification.BubbleMetadata.FLAG_SUPPRESS_NOTIFICATION; 650 } 651 652 if (showInShade() != prevShowInShade && mBubbleMetadataFlagListener != null) { 653 mBubbleMetadataFlagListener.onBubbleMetadataFlagChanged(this); 654 } 655 } 656 657 /** 658 * Sets whether this bubble should be suppressed from the stack. 659 */ setSuppressBubble(boolean suppressBubble)660 public void setSuppressBubble(boolean suppressBubble) { 661 if (!isSuppressable()) { 662 Log.e(TAG, "calling setSuppressBubble on " 663 + getKey() + " when bubble not suppressable"); 664 return; 665 } 666 boolean prevSuppressed = isSuppressed(); 667 if (suppressBubble) { 668 mFlags |= Notification.BubbleMetadata.FLAG_SUPPRESS_BUBBLE; 669 } else { 670 mFlags &= ~Notification.BubbleMetadata.FLAG_SUPPRESS_BUBBLE; 671 } 672 if (prevSuppressed != suppressBubble && mBubbleMetadataFlagListener != null) { 673 mBubbleMetadataFlagListener.onBubbleMetadataFlagChanged(this); 674 } 675 } 676 677 /** 678 * Sets whether the bubble for this notification should show a dot indicating updated content. 679 */ setShowDot(boolean showDot)680 void setShowDot(boolean showDot) { 681 mShowBubbleUpdateDot = showDot; 682 683 if (mIconView != null) { 684 mIconView.updateDotVisibility(true /* animate */); 685 } 686 } 687 688 /** 689 * Whether the bubble for this notification should show a dot indicating updated content. 690 */ 691 @Override showDot()692 public boolean showDot() { 693 return mShowBubbleUpdateDot 694 && !mShouldSuppressNotificationDot 695 && !shouldSuppressNotification(); 696 } 697 698 /** 699 * Whether the flyout for the bubble should be shown. 700 */ 701 @VisibleForTesting showFlyout()702 public boolean showFlyout() { 703 return !mSuppressFlyout && !mShouldSuppressPeek 704 && !shouldSuppressNotification() 705 && !mShouldSuppressNotificationList; 706 } 707 708 /** 709 * Set whether the flyout text for the bubble should be shown when an update is received. 710 * 711 * @param suppressFlyout whether the flyout text is shown 712 */ setSuppressFlyout(boolean suppressFlyout)713 void setSuppressFlyout(boolean suppressFlyout) { 714 mSuppressFlyout = suppressFlyout; 715 } 716 getFlyoutMessage()717 FlyoutMessage getFlyoutMessage() { 718 return mFlyoutMessage; 719 } 720 getRawDesiredHeight()721 int getRawDesiredHeight() { 722 return mDesiredHeight; 723 } 724 getRawDesiredHeightResId()725 int getRawDesiredHeightResId() { 726 return mDesiredHeightResId; 727 } 728 getDesiredHeight(Context context)729 float getDesiredHeight(Context context) { 730 boolean useRes = mDesiredHeightResId != 0; 731 if (useRes) { 732 return getDimenForPackageUser(context, mDesiredHeightResId, mPackageName, 733 mUser.getIdentifier()); 734 } else { 735 return mDesiredHeight * context.getResources().getDisplayMetrics().density; 736 } 737 } 738 getDesiredHeightString()739 String getDesiredHeightString() { 740 boolean useRes = mDesiredHeightResId != 0; 741 if (useRes) { 742 return String.valueOf(mDesiredHeightResId); 743 } else { 744 return String.valueOf(mDesiredHeight); 745 } 746 } 747 748 @Nullable getBubbleIntent()749 PendingIntent getBubbleIntent() { 750 return mIntent; 751 } 752 753 @Nullable getDeleteIntent()754 PendingIntent getDeleteIntent() { 755 return mDeleteIntent; 756 } 757 758 @Nullable getAppBubbleIntent()759 Intent getAppBubbleIntent() { 760 return mAppIntent; 761 } 762 isAppBubble()763 boolean isAppBubble() { 764 return KEY_APP_BUBBLE.equals(mKey); 765 } 766 getSettingsIntent(final Context context)767 Intent getSettingsIntent(final Context context) { 768 final Intent intent = new Intent(Settings.ACTION_APP_NOTIFICATION_BUBBLE_SETTINGS); 769 intent.putExtra(Settings.EXTRA_APP_PACKAGE, getPackageName()); 770 final int uid = getUid(context); 771 if (uid != -1) { 772 intent.putExtra(Settings.EXTRA_APP_UID, uid); 773 } 774 intent.addFlags(Intent.FLAG_ACTIVITY_MULTIPLE_TASK); 775 intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); 776 intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP); 777 return intent; 778 } 779 getAppUid()780 public int getAppUid() { 781 return mAppUid; 782 } 783 getUid(final Context context)784 private int getUid(final Context context) { 785 if (mAppUid != -1) return mAppUid; 786 final PackageManager pm = BubbleController.getPackageManagerForUser(context, 787 mUser.getIdentifier()); 788 if (pm == null) return -1; 789 try { 790 final ApplicationInfo info = pm.getApplicationInfo(mShortcutInfo.getPackage(), 0); 791 return info.uid; 792 } catch (PackageManager.NameNotFoundException e) { 793 Log.e(TAG, "cannot find uid", e); 794 } 795 return -1; 796 } 797 getDimenForPackageUser(Context context, int resId, String pkg, int userId)798 private int getDimenForPackageUser(Context context, int resId, String pkg, int userId) { 799 Resources r; 800 if (pkg != null) { 801 try { 802 if (userId == UserHandle.USER_ALL) { 803 userId = UserHandle.USER_SYSTEM; 804 } 805 r = context.createContextAsUser(UserHandle.of(userId), /* flags */ 0) 806 .getPackageManager().getResourcesForApplication(pkg); 807 return r.getDimensionPixelSize(resId); 808 } catch (PackageManager.NameNotFoundException ex) { 809 // Uninstalled, don't care 810 } catch (Resources.NotFoundException e) { 811 // Invalid res id, return 0 and user our default 812 Log.e(TAG, "Couldn't find desired height res id", e); 813 } 814 } 815 return 0; 816 } 817 shouldSuppressNotification()818 private boolean shouldSuppressNotification() { 819 return isEnabled(Notification.BubbleMetadata.FLAG_SUPPRESS_NOTIFICATION); 820 } 821 shouldAutoExpand()822 public boolean shouldAutoExpand() { 823 return isEnabled(Notification.BubbleMetadata.FLAG_AUTO_EXPAND_BUBBLE); 824 } 825 826 @VisibleForTesting setShouldAutoExpand(boolean shouldAutoExpand)827 public void setShouldAutoExpand(boolean shouldAutoExpand) { 828 boolean prevAutoExpand = shouldAutoExpand(); 829 if (shouldAutoExpand) { 830 enable(Notification.BubbleMetadata.FLAG_AUTO_EXPAND_BUBBLE); 831 } else { 832 disable(Notification.BubbleMetadata.FLAG_AUTO_EXPAND_BUBBLE); 833 } 834 if (prevAutoExpand != shouldAutoExpand && mBubbleMetadataFlagListener != null) { 835 mBubbleMetadataFlagListener.onBubbleMetadataFlagChanged(this); 836 } 837 } 838 setIsBubble(final boolean isBubble)839 public void setIsBubble(final boolean isBubble) { 840 mIsBubble = isBubble; 841 } 842 isBubble()843 public boolean isBubble() { 844 return mIsBubble; 845 } 846 enable(int option)847 public void enable(int option) { 848 mFlags |= option; 849 } 850 disable(int option)851 public void disable(int option) { 852 mFlags &= ~option; 853 } 854 isEnabled(int option)855 public boolean isEnabled(int option) { 856 return (mFlags & option) != 0; 857 } 858 getFlags()859 public int getFlags() { 860 return mFlags; 861 } 862 863 @Override toString()864 public String toString() { 865 return "Bubble{" + mKey + '}'; 866 } 867 868 /** 869 * Description of current bubble state. 870 */ dump(@onNull PrintWriter pw)871 public void dump(@NonNull PrintWriter pw) { 872 pw.print("key: "); pw.println(mKey); 873 pw.print(" showInShade: "); pw.println(showInShade()); 874 pw.print(" showDot: "); pw.println(showDot()); 875 pw.print(" showFlyout: "); pw.println(showFlyout()); 876 pw.print(" lastActivity: "); pw.println(getLastActivity()); 877 pw.print(" desiredHeight: "); pw.println(getDesiredHeightString()); 878 pw.print(" suppressNotif: "); pw.println(shouldSuppressNotification()); 879 pw.print(" autoExpand: "); pw.println(shouldAutoExpand()); 880 pw.print(" isDismissable: "); pw.println(mIsDismissable); 881 pw.println(" bubbleMetadataFlagListener null: " + (mBubbleMetadataFlagListener == null)); 882 if (mExpandedView != null) { 883 mExpandedView.dump(pw); 884 } 885 } 886 887 @Override equals(Object o)888 public boolean equals(Object o) { 889 if (this == o) return true; 890 if (!(o instanceof Bubble)) return false; 891 Bubble bubble = (Bubble) o; 892 return Objects.equals(mKey, bubble.mKey); 893 } 894 895 @Override hashCode()896 public int hashCode() { 897 return Objects.hash(mKey); 898 } 899 900 @Nullable getTitle(@onNull final BubbleEntry e)901 private static String getTitle(@NonNull final BubbleEntry e) { 902 final CharSequence titleCharSeq = e.getStatusBarNotification() 903 .getNotification().extras.getCharSequence(Notification.EXTRA_TITLE); 904 return titleCharSeq == null ? null : titleCharSeq.toString(); 905 } 906 907 /** 908 * Returns our best guess for the most relevant text summary of the latest update to this 909 * notification, based on its type. Returns null if there should not be an update message. 910 */ 911 @NonNull extractFlyoutMessage(BubbleEntry entry)912 static Bubble.FlyoutMessage extractFlyoutMessage(BubbleEntry entry) { 913 Objects.requireNonNull(entry); 914 final Notification underlyingNotif = entry.getStatusBarNotification().getNotification(); 915 final Class<? extends Notification.Style> style = underlyingNotif.getNotificationStyle(); 916 917 Bubble.FlyoutMessage bubbleMessage = new Bubble.FlyoutMessage(); 918 bubbleMessage.isGroupChat = underlyingNotif.extras.getBoolean( 919 Notification.EXTRA_IS_GROUP_CONVERSATION); 920 try { 921 if (Notification.BigTextStyle.class.equals(style)) { 922 // Return the big text, it is big so probably important. If it's not there use the 923 // normal text. 924 CharSequence bigText = 925 underlyingNotif.extras.getCharSequence(Notification.EXTRA_BIG_TEXT); 926 bubbleMessage.message = !TextUtils.isEmpty(bigText) 927 ? bigText 928 : underlyingNotif.extras.getCharSequence(Notification.EXTRA_TEXT); 929 return bubbleMessage; 930 } else if (Notification.MessagingStyle.class.equals(style)) { 931 final List<Notification.MessagingStyle.Message> messages = 932 Notification.MessagingStyle.Message.getMessagesFromBundleArray( 933 (Parcelable[]) underlyingNotif.extras.get( 934 Notification.EXTRA_MESSAGES)); 935 936 final Notification.MessagingStyle.Message latestMessage = 937 Notification.MessagingStyle.findLatestIncomingMessage(messages); 938 if (latestMessage != null) { 939 bubbleMessage.message = latestMessage.getText(); 940 Person sender = latestMessage.getSenderPerson(); 941 bubbleMessage.senderName = sender != null ? sender.getName() : null; 942 bubbleMessage.senderAvatar = null; 943 bubbleMessage.senderIcon = sender != null ? sender.getIcon() : null; 944 return bubbleMessage; 945 } 946 } else if (Notification.InboxStyle.class.equals(style)) { 947 CharSequence[] lines = 948 underlyingNotif.extras.getCharSequenceArray(Notification.EXTRA_TEXT_LINES); 949 950 // Return the last line since it should be the most recent. 951 if (lines != null && lines.length > 0) { 952 bubbleMessage.message = lines[lines.length - 1]; 953 return bubbleMessage; 954 } 955 } else if (Notification.MediaStyle.class.equals(style)) { 956 // Return nothing, media updates aren't typically useful as a text update. 957 return bubbleMessage; 958 } else { 959 // Default to text extra. 960 bubbleMessage.message = 961 underlyingNotif.extras.getCharSequence(Notification.EXTRA_TEXT); 962 return bubbleMessage; 963 } 964 } catch (ClassCastException | NullPointerException | ArrayIndexOutOfBoundsException e) { 965 // No use crashing, we'll just return null and the caller will assume there's no update 966 // message. 967 e.printStackTrace(); 968 } 969 970 return bubbleMessage; 971 } 972 } 973