1 /* 2 * Copyright (C) 2019 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.systemui.bubbles; 17 18 19 import static android.view.Display.INVALID_DISPLAY; 20 21 import static com.android.internal.annotations.VisibleForTesting.Visibility.PRIVATE; 22 23 import android.annotation.NonNull; 24 import android.annotation.Nullable; 25 import android.app.Notification; 26 import android.app.PendingIntent; 27 import android.content.Context; 28 import android.content.Intent; 29 import android.content.pm.ApplicationInfo; 30 import android.content.pm.PackageManager; 31 import android.content.res.Resources; 32 import android.graphics.drawable.Drawable; 33 import android.os.Parcelable; 34 import android.os.UserHandle; 35 import android.provider.Settings; 36 import android.text.TextUtils; 37 import android.util.Log; 38 import android.view.LayoutInflater; 39 40 import com.android.internal.annotations.VisibleForTesting; 41 import com.android.systemui.R; 42 import com.android.systemui.statusbar.notification.collection.NotificationEntry; 43 44 import java.io.FileDescriptor; 45 import java.io.PrintWriter; 46 import java.util.List; 47 import java.util.Objects; 48 49 /** 50 * Encapsulates the data and UI elements of a bubble. 51 */ 52 class Bubble { 53 private static final String TAG = "Bubble"; 54 55 private NotificationEntry mEntry; 56 private final String mKey; 57 private final String mGroupId; 58 private String mAppName; 59 private Drawable mUserBadgedAppIcon; 60 61 private boolean mInflated; 62 private BubbleView mIconView; 63 private BubbleExpandedView mExpandedView; 64 65 private long mLastUpdated; 66 private long mLastAccessed; 67 private boolean mIsRemoved; 68 69 /** 70 * Whether this notification should be shown in the shade when it is also displayed as a bubble. 71 * 72 * <p>When a notification is a bubble we don't show it in the shade once the bubble has been 73 * expanded</p> 74 */ 75 private boolean mShowInShadeWhenBubble = true; 76 77 /** 78 * Whether the bubble should show a dot for the notification indicating updated content. 79 */ 80 private boolean mShowBubbleUpdateDot = true; 81 82 /** Whether flyout text should be suppressed, regardless of any other flags or state. */ 83 private boolean mSuppressFlyout; 84 groupId(NotificationEntry entry)85 public static String groupId(NotificationEntry entry) { 86 UserHandle user = entry.notification.getUser(); 87 return user.getIdentifier() + "|" + entry.notification.getPackageName(); 88 } 89 90 /** Used in tests when no UI is required. */ 91 @VisibleForTesting(visibility = PRIVATE) Bubble(Context context, NotificationEntry e)92 Bubble(Context context, NotificationEntry e) { 93 mEntry = e; 94 mKey = e.key; 95 mLastUpdated = e.notification.getPostTime(); 96 mGroupId = groupId(e); 97 98 PackageManager pm = context.getPackageManager(); 99 ApplicationInfo info; 100 try { 101 info = pm.getApplicationInfo( 102 mEntry.notification.getPackageName(), 103 PackageManager.MATCH_UNINSTALLED_PACKAGES 104 | PackageManager.MATCH_DISABLED_COMPONENTS 105 | PackageManager.MATCH_DIRECT_BOOT_UNAWARE 106 | PackageManager.MATCH_DIRECT_BOOT_AWARE); 107 if (info != null) { 108 mAppName = String.valueOf(pm.getApplicationLabel(info)); 109 } 110 Drawable appIcon = pm.getApplicationIcon(mEntry.notification.getPackageName()); 111 mUserBadgedAppIcon = pm.getUserBadgedIcon(appIcon, mEntry.notification.getUser()); 112 } catch (PackageManager.NameNotFoundException unused) { 113 mAppName = mEntry.notification.getPackageName(); 114 } 115 } 116 getKey()117 public String getKey() { 118 return mKey; 119 } 120 getEntry()121 public NotificationEntry getEntry() { 122 return mEntry; 123 } 124 getGroupId()125 public String getGroupId() { 126 return mGroupId; 127 } 128 getPackageName()129 public String getPackageName() { 130 return mEntry.notification.getPackageName(); 131 } 132 getAppName()133 public String getAppName() { 134 return mAppName; 135 } 136 isInflated()137 boolean isInflated() { 138 return mInflated; 139 } 140 updateDotVisibility()141 void updateDotVisibility() { 142 if (mIconView != null) { 143 mIconView.updateDotVisibility(true /* animate */); 144 } 145 } 146 getIconView()147 BubbleView getIconView() { 148 return mIconView; 149 } 150 getExpandedView()151 BubbleExpandedView getExpandedView() { 152 return mExpandedView; 153 } 154 cleanupExpandedState()155 void cleanupExpandedState() { 156 if (mExpandedView != null) { 157 mExpandedView.cleanUpExpandedState(); 158 } 159 } 160 inflate(LayoutInflater inflater, BubbleStackView stackView)161 void inflate(LayoutInflater inflater, BubbleStackView stackView) { 162 if (mInflated) { 163 return; 164 } 165 mIconView = (BubbleView) inflater.inflate( 166 R.layout.bubble_view, stackView, false /* attachToRoot */); 167 mIconView.setBubble(this); 168 mIconView.setAppIcon(mUserBadgedAppIcon); 169 170 mExpandedView = (BubbleExpandedView) inflater.inflate( 171 R.layout.bubble_expanded_view, stackView, false /* attachToRoot */); 172 mExpandedView.setBubble(this, stackView, mAppName); 173 174 mInflated = true; 175 } 176 177 /** 178 * Set visibility of bubble in the expanded state. 179 * 180 * @param visibility {@code true} if the expanded bubble should be visible on the screen. 181 * 182 * Note that this contents visibility doesn't affect visibility at {@link android.view.View}, 183 * and setting {@code false} actually means rendering the expanded view in transparent. 184 */ setContentVisibility(boolean visibility)185 void setContentVisibility(boolean visibility) { 186 if (mExpandedView != null) { 187 mExpandedView.setContentVisibility(visibility); 188 } 189 } 190 updateEntry(NotificationEntry entry)191 void updateEntry(NotificationEntry entry) { 192 mEntry = entry; 193 mLastUpdated = entry.notification.getPostTime(); 194 if (mInflated) { 195 mIconView.update(this); 196 mExpandedView.update(this); 197 } 198 } 199 200 /** 201 * @return the newer of {@link #getLastUpdateTime()} and {@link #getLastAccessTime()} 202 */ getLastActivity()203 long getLastActivity() { 204 return Math.max(mLastUpdated, mLastAccessed); 205 } 206 207 /** 208 * @return the timestamp in milliseconds of the most recent notification entry for this bubble 209 */ getLastUpdateTime()210 long getLastUpdateTime() { 211 return mLastUpdated; 212 } 213 214 /** 215 * @return the timestamp in milliseconds when this bubble was last displayed in expanded state 216 */ getLastAccessTime()217 long getLastAccessTime() { 218 return mLastAccessed; 219 } 220 221 /** 222 * @return the display id of the virtual display on which bubble contents is drawn. 223 */ getDisplayId()224 int getDisplayId() { 225 return mExpandedView != null ? mExpandedView.getVirtualDisplayId() : INVALID_DISPLAY; 226 } 227 228 /** 229 * Should be invoked whenever a Bubble is accessed (selected while expanded). 230 */ markAsAccessedAt(long lastAccessedMillis)231 void markAsAccessedAt(long lastAccessedMillis) { 232 mLastAccessed = lastAccessedMillis; 233 setShowInShadeWhenBubble(false); 234 setShowBubbleDot(false); 235 } 236 237 /** 238 * Whether this notification should be shown in the shade when it is also displayed as a 239 * bubble. 240 */ showInShadeWhenBubble()241 boolean showInShadeWhenBubble() { 242 return !mEntry.isRowDismissed() && !shouldSuppressNotification() 243 && (!mEntry.isClearable() || mShowInShadeWhenBubble); 244 } 245 246 /** 247 * Sets whether this notification should be shown in the shade when it is also displayed as a 248 * bubble. 249 */ setShowInShadeWhenBubble(boolean showInShade)250 void setShowInShadeWhenBubble(boolean showInShade) { 251 mShowInShadeWhenBubble = showInShade; 252 } 253 254 /** 255 * Sets whether the bubble for this notification should show a dot indicating updated content. 256 */ setShowBubbleDot(boolean showDot)257 void setShowBubbleDot(boolean showDot) { 258 mShowBubbleUpdateDot = showDot; 259 } 260 261 /** 262 * Whether the bubble for this notification should show a dot indicating updated content. 263 */ showBubbleDot()264 boolean showBubbleDot() { 265 return mShowBubbleUpdateDot && !mEntry.shouldSuppressNotificationDot(); 266 } 267 268 /** 269 * Whether the flyout for the bubble should be shown. 270 */ showFlyoutForBubble()271 boolean showFlyoutForBubble() { 272 return !mSuppressFlyout && !mEntry.shouldSuppressPeek() 273 && !mEntry.shouldSuppressNotificationList(); 274 } 275 276 /** 277 * Set whether the flyout text for the bubble should be shown when an update is received. 278 * 279 * @param suppressFlyout whether the flyout text is shown 280 */ setSuppressFlyout(boolean suppressFlyout)281 void setSuppressFlyout(boolean suppressFlyout) { 282 mSuppressFlyout = suppressFlyout; 283 } 284 285 /** 286 * Returns whether the notification for this bubble is a foreground service. It shows that this 287 * is an ongoing bubble. 288 */ isOngoing()289 boolean isOngoing() { 290 int flags = mEntry.notification.getNotification().flags; 291 return (flags & Notification.FLAG_FOREGROUND_SERVICE) != 0; 292 } 293 getDesiredHeight(Context context)294 float getDesiredHeight(Context context) { 295 Notification.BubbleMetadata data = mEntry.getBubbleMetadata(); 296 boolean useRes = data.getDesiredHeightResId() != 0; 297 if (useRes) { 298 return getDimenForPackageUser(context, data.getDesiredHeightResId(), 299 mEntry.notification.getPackageName(), 300 mEntry.notification.getUser().getIdentifier()); 301 } else { 302 return data.getDesiredHeight() 303 * context.getResources().getDisplayMetrics().density; 304 } 305 } 306 getDesiredHeightString()307 String getDesiredHeightString() { 308 Notification.BubbleMetadata data = mEntry.getBubbleMetadata(); 309 boolean useRes = data.getDesiredHeightResId() != 0; 310 if (useRes) { 311 return String.valueOf(data.getDesiredHeightResId()); 312 } else { 313 return String.valueOf(data.getDesiredHeight()); 314 } 315 } 316 317 @Nullable getBubbleIntent(Context context)318 PendingIntent getBubbleIntent(Context context) { 319 Notification notif = mEntry.notification.getNotification(); 320 Notification.BubbleMetadata data = notif.getBubbleMetadata(); 321 if (BubbleController.canLaunchInActivityView(context, mEntry) && data != null) { 322 return data.getIntent(); 323 } 324 return null; 325 } 326 getSettingsIntent()327 Intent getSettingsIntent() { 328 final Intent intent = new Intent(Settings.ACTION_APP_NOTIFICATION_BUBBLE_SETTINGS); 329 intent.putExtra(Settings.EXTRA_APP_PACKAGE, getPackageName()); 330 intent.putExtra(Settings.EXTRA_APP_UID, mEntry.notification.getUid()); 331 intent.addFlags(Intent.FLAG_ACTIVITY_MULTIPLE_TASK); 332 intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); 333 intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP); 334 return intent; 335 } 336 337 /** 338 * Returns our best guess for the most relevant text summary of the latest update to this 339 * notification, based on its type. Returns null if there should not be an update message. 340 */ getUpdateMessage(Context context)341 CharSequence getUpdateMessage(Context context) { 342 final Notification underlyingNotif = mEntry.notification.getNotification(); 343 final Class<? extends Notification.Style> style = underlyingNotif.getNotificationStyle(); 344 345 try { 346 if (Notification.BigTextStyle.class.equals(style)) { 347 // Return the big text, it is big so probably important. If it's not there use the 348 // normal text. 349 CharSequence bigText = 350 underlyingNotif.extras.getCharSequence(Notification.EXTRA_BIG_TEXT); 351 return !TextUtils.isEmpty(bigText) 352 ? bigText 353 : underlyingNotif.extras.getCharSequence(Notification.EXTRA_TEXT); 354 } else if (Notification.MessagingStyle.class.equals(style)) { 355 final List<Notification.MessagingStyle.Message> messages = 356 Notification.MessagingStyle.Message.getMessagesFromBundleArray( 357 (Parcelable[]) underlyingNotif.extras.get( 358 Notification.EXTRA_MESSAGES)); 359 360 final Notification.MessagingStyle.Message latestMessage = 361 Notification.MessagingStyle.findLatestIncomingMessage(messages); 362 363 if (latestMessage != null) { 364 final CharSequence personName = latestMessage.getSenderPerson() != null 365 ? latestMessage.getSenderPerson().getName() 366 : null; 367 368 // Prepend the sender name if available since group chats also use messaging 369 // style. 370 if (!TextUtils.isEmpty(personName)) { 371 return context.getResources().getString( 372 R.string.notification_summary_message_format, 373 personName, 374 latestMessage.getText()); 375 } else { 376 return latestMessage.getText(); 377 } 378 } 379 } else if (Notification.InboxStyle.class.equals(style)) { 380 CharSequence[] lines = 381 underlyingNotif.extras.getCharSequenceArray(Notification.EXTRA_TEXT_LINES); 382 383 // Return the last line since it should be the most recent. 384 if (lines != null && lines.length > 0) { 385 return lines[lines.length - 1]; 386 } 387 } else if (Notification.MediaStyle.class.equals(style)) { 388 // Return nothing, media updates aren't typically useful as a text update. 389 return null; 390 } else { 391 // Default to text extra. 392 return underlyingNotif.extras.getCharSequence(Notification.EXTRA_TEXT); 393 } 394 } catch (ClassCastException | NullPointerException | ArrayIndexOutOfBoundsException e) { 395 // No use crashing, we'll just return null and the caller will assume there's no update 396 // message. 397 e.printStackTrace(); 398 } 399 400 return null; 401 } 402 getDimenForPackageUser(Context context, int resId, String pkg, int userId)403 private int getDimenForPackageUser(Context context, int resId, String pkg, int userId) { 404 PackageManager pm = context.getPackageManager(); 405 Resources r; 406 if (pkg != null) { 407 try { 408 if (userId == UserHandle.USER_ALL) { 409 userId = UserHandle.USER_SYSTEM; 410 } 411 r = pm.getResourcesForApplicationAsUser(pkg, userId); 412 return r.getDimensionPixelSize(resId); 413 } catch (PackageManager.NameNotFoundException ex) { 414 // Uninstalled, don't care 415 } catch (Resources.NotFoundException e) { 416 // Invalid res id, return 0 and user our default 417 Log.e(TAG, "Couldn't find desired height res id", e); 418 } 419 } 420 return 0; 421 } 422 shouldSuppressNotification()423 private boolean shouldSuppressNotification() { 424 return mEntry.getBubbleMetadata() != null 425 && mEntry.getBubbleMetadata().isNotificationSuppressed(); 426 } 427 shouldAutoExpand()428 boolean shouldAutoExpand() { 429 Notification.BubbleMetadata metadata = mEntry.getBubbleMetadata(); 430 return metadata != null && metadata.getAutoExpandBubble(); 431 } 432 433 @Override toString()434 public String toString() { 435 return "Bubble{" + mKey + '}'; 436 } 437 438 /** 439 * Description of current bubble state. 440 */ dump( @onNull FileDescriptor fd, @NonNull PrintWriter pw, @NonNull String[] args)441 public void dump( 442 @NonNull FileDescriptor fd, @NonNull PrintWriter pw, @NonNull String[] args) { 443 pw.print("key: "); pw.println(mKey); 444 pw.print(" showInShade: "); pw.println(showInShadeWhenBubble()); 445 pw.print(" showDot: "); pw.println(showBubbleDot()); 446 pw.print(" showFlyout: "); pw.println(showFlyoutForBubble()); 447 pw.print(" desiredHeight: "); pw.println(getDesiredHeightString()); 448 pw.print(" suppressNotif: "); pw.println(shouldSuppressNotification()); 449 pw.print(" autoExpand: "); pw.println(shouldAutoExpand()); 450 } 451 452 @Override equals(Object o)453 public boolean equals(Object o) { 454 if (this == o) return true; 455 if (!(o instanceof Bubble)) return false; 456 Bubble bubble = (Bubble) o; 457 return Objects.equals(mKey, bubble.mKey); 458 } 459 460 @Override hashCode()461 public int hashCode() { 462 return Objects.hash(mKey); 463 } 464 } 465