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