• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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