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