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