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