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 17 package com.android.systemui.statusbar; 18 19 import static android.app.Flags.notificationsRedesignTemplates; 20 21 import android.app.Flags; 22 import android.app.Notification; 23 import android.graphics.drawable.Drawable; 24 import android.graphics.drawable.Icon; 25 import android.service.notification.StatusBarNotification; 26 import android.text.TextUtils; 27 import android.util.DisplayMetrics; 28 import android.util.TypedValue; 29 import android.view.NotificationHeaderView; 30 import android.view.View; 31 import android.view.ViewGroup; 32 import android.widget.ImageView; 33 import android.widget.TextView; 34 35 import androidx.annotation.VisibleForTesting; 36 37 import com.android.internal.R; 38 import com.android.internal.widget.CachingIconView; 39 import com.android.internal.widget.ConversationLayout; 40 import com.android.internal.widget.ImageFloatingTextView; 41 import com.android.systemui.statusbar.notification.icon.IconPack; 42 import com.android.systemui.statusbar.notification.row.ExpandableNotificationRow; 43 import com.android.systemui.statusbar.notification.row.NotificationContentView; 44 import com.android.systemui.statusbar.notification.row.shared.AsyncGroupHeaderViewInflation; 45 import com.android.systemui.statusbar.notification.row.wrapper.NotificationViewWrapper; 46 import com.android.systemui.statusbar.notification.shared.NotificationBundleUi; 47 48 import java.util.ArrayList; 49 import java.util.HashSet; 50 import java.util.List; 51 import java.util.Objects; 52 53 /** 54 * A utility to manage notification views when they are placed in a group by adjusting elements 55 * to reduce redundancies and occasionally tweak layouts to highlight the unique content. 56 */ 57 public class NotificationGroupingUtil { 58 59 private static final TextViewComparator TEXT_VIEW_COMPARATOR = new TextViewComparator(); 60 private static final TextViewComparator APP_NAME_COMPARATOR = new AppNameComparator(); 61 private static final ViewComparator BADGE_COMPARATOR = new BadgeComparator(); 62 private static final VisibilityApplicator VISIBILITY_APPLICATOR = new VisibilityApplicator(); 63 private static final VisibilityApplicator APP_NAME_APPLICATOR = new AppNameApplicator(); 64 private static final ResultApplicator LEFT_ICON_APPLICATOR = new LeftIconApplicator(); 65 66 @VisibleForTesting 67 static final DataExtractor ICON_EXTRACTOR = new DataExtractor() { 68 @Override 69 public Object extractData(ExpandableNotificationRow row) { 70 if (NotificationBundleUi.isEnabled()) { 71 if (row.getEntryAdapter().getSbn() != null) { 72 return row.getEntryAdapter().getSbn().getNotification(); 73 } 74 return null; 75 } else { 76 return row.getEntryLegacy().getSbn().getNotification(); 77 } 78 } 79 }; 80 81 private final ExpandableNotificationRow mRow; 82 private final ArrayList<Processor> mProcessors = new ArrayList<>(); 83 private final HashSet<Integer> mDividers = new HashSet<>(); 84 NotificationGroupingUtil(ExpandableNotificationRow row)85 public NotificationGroupingUtil(ExpandableNotificationRow row) { 86 mRow = row; 87 88 final IconComparator iconVisibilityComparator = new IconComparator() { 89 @Override 90 public boolean compare(View parent, View child, Object parentData, 91 Object childData) { 92 if (Flags.notificationsRedesignAppIcons() && mRow.isShowingAppIcon()) { 93 // Icon is always the same when we're showing the app icon. 94 return true; 95 } 96 return hasSameIcon(parentData, childData) 97 && hasSameColor(parentData, childData); 98 } 99 }; 100 final IconComparator greyComparator = new IconComparator() { 101 @Override 102 public boolean compare(View parent, View child, Object parentData, 103 Object childData) { 104 if (Flags.notificationsRedesignAppIcons() && mRow.isShowingAppIcon()) { 105 return false; 106 } 107 return !hasSameIcon(parentData, childData) 108 || hasSameColor(parentData, childData); 109 } 110 }; 111 final ResultApplicator greyApplicator = new ResultApplicator() { 112 @Override 113 public void apply(View parent, View view, boolean apply, boolean reset) { 114 if (Flags.notificationsRedesignAppIcons() && mRow.isShowingAppIcon()) { 115 // Do nothing. 116 return; 117 } 118 CachingIconView icon = view.findViewById(com.android.internal.R.id.icon); 119 if (icon != null) { 120 icon.setGrayedOut(apply); 121 } 122 } 123 }; 124 125 // To hide the icons if they are the same and the color is the same 126 mProcessors.add(new Processor(mRow, 127 com.android.internal.R.id.icon, 128 ICON_EXTRACTOR, 129 iconVisibilityComparator, 130 VISIBILITY_APPLICATOR)); 131 // To grey out the icons when they are not the same, or they have the same color 132 mProcessors.add(new Processor(mRow, 133 com.android.internal.R.id.status_bar_latest_event_content, 134 ICON_EXTRACTOR, 135 greyComparator, 136 greyApplicator)); 137 // To show the large icon on the left side instead if all the small icons are the same 138 mProcessors.add(new Processor(mRow, 139 com.android.internal.R.id.status_bar_latest_event_content, 140 ICON_EXTRACTOR, 141 iconVisibilityComparator, 142 LEFT_ICON_APPLICATOR)); 143 // To only show the work profile icon in the group header 144 mProcessors.add(new Processor(mRow, 145 com.android.internal.R.id.profile_badge, 146 null /* Extractor */, 147 BADGE_COMPARATOR, 148 VISIBILITY_APPLICATOR)); 149 // To hide the app name in group children 150 mProcessors.add(new Processor(mRow, 151 com.android.internal.R.id.app_name_text, 152 null, 153 APP_NAME_COMPARATOR, 154 APP_NAME_APPLICATOR)); 155 // To hide the header text if it's the same 156 mProcessors.add(Processor.forTextView(mRow, com.android.internal.R.id.header_text)); 157 158 mDividers.add(com.android.internal.R.id.header_text_divider); 159 mDividers.add(com.android.internal.R.id.header_text_secondary_divider); 160 mDividers.add(com.android.internal.R.id.time_divider); 161 } 162 163 /** 164 * Update the appearance of the children in this group to reduce redundancies. 165 */ updateChildrenAppearance()166 public void updateChildrenAppearance() { 167 List<ExpandableNotificationRow> notificationChildren = mRow.getAttachedChildren(); 168 if (notificationChildren == null || !mRow.isSummaryWithChildren()) { 169 return; 170 } 171 // Initialize the processors 172 for (int compI = 0; compI < mProcessors.size(); compI++) { 173 mProcessors.get(compI).init(); 174 } 175 176 // Compare all notification headers 177 for (int i = 0; i < notificationChildren.size(); i++) { 178 ExpandableNotificationRow row = notificationChildren.get(i); 179 for (int compI = 0; compI < mProcessors.size(); compI++) { 180 mProcessors.get(compI).compareToGroupParent(row); 181 } 182 } 183 184 // Apply the comparison to the row 185 for (int i = 0; i < notificationChildren.size(); i++) { 186 ExpandableNotificationRow row = notificationChildren.get(i); 187 for (int compI = 0; compI < mProcessors.size(); compI++) { 188 mProcessors.get(compI).apply(row); 189 } 190 // We need to sanitize the dividers since they might be off-balance now 191 sanitizeTopLineViews(row); 192 } 193 } 194 sanitizeTopLineViews(ExpandableNotificationRow row)195 private void sanitizeTopLineViews(ExpandableNotificationRow row) { 196 if (row.isSummaryWithChildren()) { 197 sanitizeTopLine(row.getNotificationViewWrapper().getNotificationHeader(), row); 198 return; 199 } 200 final NotificationContentView layout = row.getPrivateLayout(); 201 sanitizeChild(layout.getContractedChild(), row); 202 sanitizeChild(layout.getHeadsUpChild(), row); 203 sanitizeChild(layout.getExpandedChild(), row); 204 } 205 sanitizeChild(View child, ExpandableNotificationRow row)206 private void sanitizeChild(View child, ExpandableNotificationRow row) { 207 if (child != null) { 208 sanitizeTopLine(child.findViewById(R.id.notification_top_line), row); 209 } 210 } 211 sanitizeTopLine(ViewGroup rowHeader, ExpandableNotificationRow row)212 private void sanitizeTopLine(ViewGroup rowHeader, ExpandableNotificationRow row) { 213 if (rowHeader == null) { 214 return; 215 } 216 final int childCount = rowHeader.getChildCount(); 217 View time = rowHeader.findViewById(com.android.internal.R.id.time); 218 boolean hasVisibleText = false; 219 for (int i = 0; i < childCount; i++) { 220 View child = rowHeader.getChildAt(i); 221 if (child instanceof TextView 222 && child.getVisibility() != View.GONE 223 && !mDividers.contains(child.getId()) 224 && child != time) { 225 hasVisibleText = true; 226 break; 227 } 228 } 229 // in case no view is visible we make sure the time is visible 230 int timeVisibility = !hasVisibleText 231 || showsTime(row) 232 ? View.VISIBLE : View.GONE; 233 time.setVisibility(timeVisibility); 234 View left = null; 235 View right; 236 for (int i = 0; i < childCount; i++) { 237 View child = rowHeader.getChildAt(i); 238 if (mDividers.contains(child.getId())) { 239 boolean visible = false; 240 // Lets find the item to the right 241 for (i++; i < childCount; i++) { 242 right = rowHeader.getChildAt(i); 243 if (mDividers.contains(right.getId())) { 244 // A divider was found, this needs to be hidden 245 i--; 246 break; 247 } else if (right.getVisibility() != View.GONE && right instanceof TextView) { 248 visible = left != null; 249 left = right; 250 break; 251 } 252 } 253 child.setVisibility(visible ? View.VISIBLE : View.GONE); 254 } else if (child.getVisibility() != View.GONE && child instanceof TextView) { 255 left = child; 256 } 257 } 258 } 259 260 @VisibleForTesting showsTime(ExpandableNotificationRow row)261 boolean showsTime(ExpandableNotificationRow row) { 262 StatusBarNotification sbn; 263 if (NotificationBundleUi.isEnabled()) { 264 sbn = row.getEntryAdapter() != null ? row.getEntryAdapter().getSbn() : null; 265 } else { 266 sbn = row.getEntryLegacy().getSbn(); 267 } 268 return (sbn != null && sbn.getNotification().showsTime()); 269 } 270 271 /** 272 * Reset the modifications to this row for removing it from the group. 273 */ restoreChildNotification(ExpandableNotificationRow row)274 public void restoreChildNotification(ExpandableNotificationRow row) { 275 for (int compI = 0; compI < mProcessors.size(); compI++) { 276 mProcessors.get(compI).apply(row, true /* reset */); 277 } 278 sanitizeTopLineViews(row); 279 } 280 281 private static class Processor { 282 private final int mId; 283 private final DataExtractor mExtractor; 284 private final ViewComparator mComparator; 285 private final ResultApplicator mApplicator; 286 private final ExpandableNotificationRow mParentRow; 287 private boolean mApply; 288 private View mParentView; 289 private Object mParentData; 290 forTextView(ExpandableNotificationRow row, int id)291 public static Processor forTextView(ExpandableNotificationRow row, int id) { 292 return new Processor(row, id, null, TEXT_VIEW_COMPARATOR, VISIBILITY_APPLICATOR); 293 } 294 Processor(ExpandableNotificationRow row, int id, DataExtractor extractor, ViewComparator comparator, ResultApplicator applicator)295 Processor(ExpandableNotificationRow row, int id, DataExtractor extractor, 296 ViewComparator comparator, 297 ResultApplicator applicator) { 298 mId = id; 299 mExtractor = extractor; 300 mApplicator = applicator; 301 mComparator = comparator; 302 mParentRow = row; 303 } 304 init()305 public void init() { 306 NotificationViewWrapper wrapper = mParentRow.getNotificationViewWrapper(); 307 View header = wrapper == null ? null : wrapper.getNotificationHeader(); 308 mParentView = header == null ? null : header.findViewById(mId); 309 mParentData = mExtractor == null ? null : mExtractor.extractData(mParentRow); 310 mApply = !mComparator.isEmpty(mParentView); 311 } 312 compareToGroupParent(ExpandableNotificationRow row)313 public void compareToGroupParent(ExpandableNotificationRow row) { 314 if (!mApply) { 315 return; 316 } 317 View contractedChild = row.getPrivateLayout().getContractedChild(); 318 if (contractedChild == null) { 319 return; 320 } 321 View ownView = contractedChild.findViewById(mId); 322 if (ownView == null) { 323 // No view found. We still consider this to be the same to avoid weird flickering 324 // when for example showing an undo notification 325 return; 326 } 327 Object childData = mExtractor == null ? null : mExtractor.extractData(row); 328 mApply = mComparator.compare(mParentView, ownView, 329 mParentData, childData); 330 } 331 apply(ExpandableNotificationRow row)332 public void apply(ExpandableNotificationRow row) { 333 apply(row, false /* reset */); 334 } 335 apply(ExpandableNotificationRow row, boolean reset)336 public void apply(ExpandableNotificationRow row, boolean reset) { 337 boolean apply = mApply && !reset; 338 if (row.isSummaryWithChildren()) { 339 applyToView(apply, reset, row.getNotificationViewWrapper().getNotificationHeader()); 340 return; 341 } 342 applyToView(apply, reset, row.getPrivateLayout().getContractedChild()); 343 applyToView(apply, reset, row.getPrivateLayout().getHeadsUpChild()); 344 applyToView(apply, reset, row.getPrivateLayout().getExpandedChild()); 345 } 346 applyToView(boolean apply, boolean reset, View parent)347 private void applyToView(boolean apply, boolean reset, View parent) { 348 if (parent != null) { 349 View view = parent.findViewById(mId); 350 if (view != null && !mComparator.isEmpty(view)) { 351 mApplicator.apply(parent, view, apply, reset); 352 } 353 } 354 } 355 } 356 357 private interface ViewComparator { 358 /** 359 * @param parent the view with the given id in the group header 360 * @param child the view with the given id in the child notification 361 * @param parentData optional data for the parent 362 * @param childData optional data for the child 363 * @return whether to views are the same 364 */ compare(View parent, View child, Object parentData, Object childData)365 boolean compare(View parent, View child, Object parentData, Object childData); 366 isEmpty(View view)367 boolean isEmpty(View view); 368 } 369 370 @VisibleForTesting 371 interface DataExtractor { extractData(ExpandableNotificationRow row)372 Object extractData(ExpandableNotificationRow row); 373 } 374 375 private static class BadgeComparator implements ViewComparator { 376 @Override compare(View parent, View child, Object parentData, Object childData)377 public boolean compare(View parent, View child, Object parentData, Object childData) { 378 return parent.getVisibility() != View.GONE; 379 } 380 381 @Override isEmpty(View view)382 public boolean isEmpty(View view) { 383 if (AsyncGroupHeaderViewInflation.isEnabled() && view == null) { 384 return true; 385 } 386 if (view instanceof ImageView) { 387 return ((ImageView) view).getDrawable() == null; 388 } 389 return false; 390 } 391 } 392 393 private static class TextViewComparator implements ViewComparator { 394 @Override compare(View parent, View child, Object parentData, Object childData)395 public boolean compare(View parent, View child, Object parentData, Object childData) { 396 TextView parentView = (TextView) parent; 397 CharSequence parentText = parentView == null ? "" : parentView.getText(); 398 TextView childView = (TextView) child; 399 CharSequence childText = childView == null ? "" : childView.getText(); 400 return Objects.equals(parentText, childText); 401 } 402 403 @Override isEmpty(View view)404 public boolean isEmpty(View view) { 405 return view == null || TextUtils.isEmpty(((TextView) view).getText()); 406 } 407 } 408 409 @VisibleForTesting 410 static class IconComparator implements ViewComparator { 411 @Override compare(View parent, View child, Object parentData, Object childData)412 public boolean compare(View parent, View child, Object parentData, Object childData) { 413 return false; 414 } 415 hasSameIcon(Object parentData, Object childData)416 protected boolean hasSameIcon(Object parentData, Object childData) { 417 if (parentData == null || childData == null) { 418 return false; 419 } 420 Icon parentIcon = ((Notification) parentData).getSmallIcon(); 421 Icon childIcon = ((Notification) childData).getSmallIcon(); 422 return parentIcon.sameAs(childIcon); 423 } 424 425 /** 426 * @return whether two ImageViews have the same colorFilterSet or none at all 427 */ hasSameColor(Object parentData, Object childData)428 protected boolean hasSameColor(Object parentData, Object childData) { 429 if ((parentData == null && childData != null) 430 || (parentData != null && childData == null)) { 431 return false; 432 } 433 int parentColor = ((Notification) parentData).color; 434 int childColor = ((Notification) childData).color; 435 return parentColor == childColor; 436 } 437 438 @Override isEmpty(View view)439 public boolean isEmpty(View view) { 440 return false; 441 } 442 } 443 444 private interface ResultApplicator { 445 /** 446 * @param parent the root view of the child notification 447 * @param view the view with the given id in the child notification 448 * @param apply whether the state should be applied or removed 449 * @param reset if [de]application is the result of a reset 450 */ apply(View parent, View view, boolean apply, boolean reset)451 void apply(View parent, View view, boolean apply, boolean reset); 452 } 453 454 private static class VisibilityApplicator implements ResultApplicator { 455 456 @Override apply(View parent, View view, boolean apply, boolean reset)457 public void apply(View parent, View view, boolean apply, boolean reset) { 458 if (view != null) { 459 view.setVisibility(apply ? View.GONE : View.VISIBLE); 460 } 461 } 462 } 463 464 private static class AppNameApplicator extends VisibilityApplicator { 465 466 @Override apply(View parent, View view, boolean apply, boolean reset)467 public void apply(View parent, View view, boolean apply, boolean reset) { 468 if (!notificationsRedesignTemplates() 469 && reset && parent instanceof ConversationLayout) { 470 ConversationLayout layout = (ConversationLayout) parent; 471 apply = layout.shouldHideAppName(); 472 } 473 super.apply(parent, view, apply, reset); 474 } 475 } 476 477 private static class AppNameComparator extends TextViewComparator { 478 @Override compare(View parent, View child, Object parentData, Object childData)479 public boolean compare(View parent, View child, Object parentData, Object childData) { 480 if (isEmpty(child)) { 481 // In headerless notifications the AppName view exists but is usually GONE (and not 482 // populated). We need to treat this case as equal to the header in order to 483 // deduplicate the view. 484 return true; 485 } 486 return super.compare(parent, child, parentData, childData); 487 } 488 } 489 490 private static class LeftIconApplicator implements ResultApplicator { 491 492 public static final int[] MARGIN_ADJUSTED_VIEWS = { 493 R.id.text, 494 R.id.big_text, 495 R.id.title, 496 R.id.notification_main_column, 497 R.id.notification_header}; 498 499 @Override apply(View parent, View child, boolean showLeftIcon, boolean reset)500 public void apply(View parent, View child, boolean showLeftIcon, boolean reset) { 501 ImageView leftIcon = child.findViewById(com.android.internal.R.id.left_icon); 502 if (leftIcon == null) { 503 return; 504 } 505 ImageView rightIcon = child.findViewById(com.android.internal.R.id.right_icon); 506 boolean keepRightIcon = rightIcon != null && Integer.valueOf(1).equals( 507 rightIcon.getTag(R.id.tag_keep_when_showing_left_icon)); 508 boolean leftIconUsesRightIconDrawable = Integer.valueOf(1).equals( 509 leftIcon.getTag(R.id.tag_uses_right_icon_drawable)); 510 if (leftIconUsesRightIconDrawable) { 511 // Use the right drawable when showing the left, unless the right is being kept 512 Drawable rightDrawable = rightIcon == null ? null : rightIcon.getDrawable(); 513 leftIcon.setImageDrawable(showLeftIcon && !keepRightIcon ? rightDrawable : null); 514 } 515 leftIcon.setVisibility(showLeftIcon ? View.VISIBLE : View.GONE); 516 517 // update the right icon as well 518 if (rightIcon != null) { 519 boolean showRightIcon = (keepRightIcon || !showLeftIcon) 520 && rightIcon.getDrawable() != null; 521 rightIcon.setVisibility(showRightIcon ? View.VISIBLE : View.GONE); 522 for (int viewId : MARGIN_ADJUSTED_VIEWS) { 523 adjustMargins(showRightIcon, child.findViewById(viewId)); 524 } 525 } 526 } 527 adjustMargins(boolean iconVisible, View target)528 void adjustMargins(boolean iconVisible, View target) { 529 if (target == null) { 530 return; 531 } 532 if (target instanceof ImageFloatingTextView) { 533 ((ImageFloatingTextView) target).setHasImage(iconVisible); 534 return; 535 } 536 final Integer data = (Integer) target.getTag(iconVisible 537 ? com.android.internal.R.id.tag_margin_end_when_icon_visible 538 : com.android.internal.R.id.tag_margin_end_when_icon_gone); 539 if (data == null) { 540 return; 541 } 542 final DisplayMetrics metrics = target.getResources().getDisplayMetrics(); 543 final int value = TypedValue.complexToDimensionPixelOffset(data, metrics); 544 if (target instanceof NotificationHeaderView) { 545 ((NotificationHeaderView) target).setTopLineExtraMarginEnd(value); 546 } else { 547 ViewGroup.LayoutParams layoutParams = target.getLayoutParams(); 548 if (layoutParams instanceof ViewGroup.MarginLayoutParams) { 549 ((ViewGroup.MarginLayoutParams) layoutParams).setMarginEnd(value); 550 target.setLayoutParams(layoutParams); 551 } 552 } 553 } 554 } 555 } 556