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