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