/* * Copyright (C) 2020 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.systemui.statusbar; import static android.app.Flags.notificationsRedesignTemplates; import android.app.Flags; import android.app.Notification; import android.graphics.drawable.Drawable; import android.graphics.drawable.Icon; import android.service.notification.StatusBarNotification; import android.text.TextUtils; import android.util.DisplayMetrics; import android.util.TypedValue; import android.view.NotificationHeaderView; import android.view.View; import android.view.ViewGroup; import android.widget.ImageView; import android.widget.TextView; import androidx.annotation.VisibleForTesting; import com.android.internal.R; import com.android.internal.widget.CachingIconView; import com.android.internal.widget.ConversationLayout; import com.android.internal.widget.ImageFloatingTextView; import com.android.systemui.statusbar.notification.icon.IconPack; import com.android.systemui.statusbar.notification.row.ExpandableNotificationRow; import com.android.systemui.statusbar.notification.row.NotificationContentView; import com.android.systemui.statusbar.notification.row.shared.AsyncGroupHeaderViewInflation; import com.android.systemui.statusbar.notification.row.wrapper.NotificationViewWrapper; import com.android.systemui.statusbar.notification.shared.NotificationBundleUi; import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Objects; /** * A utility to manage notification views when they are placed in a group by adjusting elements * to reduce redundancies and occasionally tweak layouts to highlight the unique content. */ public class NotificationGroupingUtil { private static final TextViewComparator TEXT_VIEW_COMPARATOR = new TextViewComparator(); private static final TextViewComparator APP_NAME_COMPARATOR = new AppNameComparator(); private static final ViewComparator BADGE_COMPARATOR = new BadgeComparator(); private static final VisibilityApplicator VISIBILITY_APPLICATOR = new VisibilityApplicator(); private static final VisibilityApplicator APP_NAME_APPLICATOR = new AppNameApplicator(); private static final ResultApplicator LEFT_ICON_APPLICATOR = new LeftIconApplicator(); @VisibleForTesting static final DataExtractor ICON_EXTRACTOR = new DataExtractor() { @Override public Object extractData(ExpandableNotificationRow row) { if (NotificationBundleUi.isEnabled()) { if (row.getEntryAdapter().getSbn() != null) { return row.getEntryAdapter().getSbn().getNotification(); } return null; } else { return row.getEntryLegacy().getSbn().getNotification(); } } }; private final ExpandableNotificationRow mRow; private final ArrayList mProcessors = new ArrayList<>(); private final HashSet mDividers = new HashSet<>(); public NotificationGroupingUtil(ExpandableNotificationRow row) { mRow = row; final IconComparator iconVisibilityComparator = new IconComparator() { @Override public boolean compare(View parent, View child, Object parentData, Object childData) { if (Flags.notificationsRedesignAppIcons() && mRow.isShowingAppIcon()) { // Icon is always the same when we're showing the app icon. return true; } return hasSameIcon(parentData, childData) && hasSameColor(parentData, childData); } }; final IconComparator greyComparator = new IconComparator() { @Override public boolean compare(View parent, View child, Object parentData, Object childData) { if (Flags.notificationsRedesignAppIcons() && mRow.isShowingAppIcon()) { return false; } return !hasSameIcon(parentData, childData) || hasSameColor(parentData, childData); } }; final ResultApplicator greyApplicator = new ResultApplicator() { @Override public void apply(View parent, View view, boolean apply, boolean reset) { if (Flags.notificationsRedesignAppIcons() && mRow.isShowingAppIcon()) { // Do nothing. return; } CachingIconView icon = view.findViewById(com.android.internal.R.id.icon); if (icon != null) { icon.setGrayedOut(apply); } } }; // To hide the icons if they are the same and the color is the same mProcessors.add(new Processor(mRow, com.android.internal.R.id.icon, ICON_EXTRACTOR, iconVisibilityComparator, VISIBILITY_APPLICATOR)); // To grey out the icons when they are not the same, or they have the same color mProcessors.add(new Processor(mRow, com.android.internal.R.id.status_bar_latest_event_content, ICON_EXTRACTOR, greyComparator, greyApplicator)); // To show the large icon on the left side instead if all the small icons are the same mProcessors.add(new Processor(mRow, com.android.internal.R.id.status_bar_latest_event_content, ICON_EXTRACTOR, iconVisibilityComparator, LEFT_ICON_APPLICATOR)); // To only show the work profile icon in the group header mProcessors.add(new Processor(mRow, com.android.internal.R.id.profile_badge, null /* Extractor */, BADGE_COMPARATOR, VISIBILITY_APPLICATOR)); // To hide the app name in group children mProcessors.add(new Processor(mRow, com.android.internal.R.id.app_name_text, null, APP_NAME_COMPARATOR, APP_NAME_APPLICATOR)); // To hide the header text if it's the same mProcessors.add(Processor.forTextView(mRow, com.android.internal.R.id.header_text)); mDividers.add(com.android.internal.R.id.header_text_divider); mDividers.add(com.android.internal.R.id.header_text_secondary_divider); mDividers.add(com.android.internal.R.id.time_divider); } /** * Update the appearance of the children in this group to reduce redundancies. */ public void updateChildrenAppearance() { List notificationChildren = mRow.getAttachedChildren(); if (notificationChildren == null || !mRow.isSummaryWithChildren()) { return; } // Initialize the processors for (int compI = 0; compI < mProcessors.size(); compI++) { mProcessors.get(compI).init(); } // Compare all notification headers for (int i = 0; i < notificationChildren.size(); i++) { ExpandableNotificationRow row = notificationChildren.get(i); for (int compI = 0; compI < mProcessors.size(); compI++) { mProcessors.get(compI).compareToGroupParent(row); } } // Apply the comparison to the row for (int i = 0; i < notificationChildren.size(); i++) { ExpandableNotificationRow row = notificationChildren.get(i); for (int compI = 0; compI < mProcessors.size(); compI++) { mProcessors.get(compI).apply(row); } // We need to sanitize the dividers since they might be off-balance now sanitizeTopLineViews(row); } } private void sanitizeTopLineViews(ExpandableNotificationRow row) { if (row.isSummaryWithChildren()) { sanitizeTopLine(row.getNotificationViewWrapper().getNotificationHeader(), row); return; } final NotificationContentView layout = row.getPrivateLayout(); sanitizeChild(layout.getContractedChild(), row); sanitizeChild(layout.getHeadsUpChild(), row); sanitizeChild(layout.getExpandedChild(), row); } private void sanitizeChild(View child, ExpandableNotificationRow row) { if (child != null) { sanitizeTopLine(child.findViewById(R.id.notification_top_line), row); } } private void sanitizeTopLine(ViewGroup rowHeader, ExpandableNotificationRow row) { if (rowHeader == null) { return; } final int childCount = rowHeader.getChildCount(); View time = rowHeader.findViewById(com.android.internal.R.id.time); boolean hasVisibleText = false; for (int i = 0; i < childCount; i++) { View child = rowHeader.getChildAt(i); if (child instanceof TextView && child.getVisibility() != View.GONE && !mDividers.contains(child.getId()) && child != time) { hasVisibleText = true; break; } } // in case no view is visible we make sure the time is visible int timeVisibility = !hasVisibleText || showsTime(row) ? View.VISIBLE : View.GONE; time.setVisibility(timeVisibility); View left = null; View right; for (int i = 0; i < childCount; i++) { View child = rowHeader.getChildAt(i); if (mDividers.contains(child.getId())) { boolean visible = false; // Lets find the item to the right for (i++; i < childCount; i++) { right = rowHeader.getChildAt(i); if (mDividers.contains(right.getId())) { // A divider was found, this needs to be hidden i--; break; } else if (right.getVisibility() != View.GONE && right instanceof TextView) { visible = left != null; left = right; break; } } child.setVisibility(visible ? View.VISIBLE : View.GONE); } else if (child.getVisibility() != View.GONE && child instanceof TextView) { left = child; } } } @VisibleForTesting boolean showsTime(ExpandableNotificationRow row) { StatusBarNotification sbn; if (NotificationBundleUi.isEnabled()) { sbn = row.getEntryAdapter() != null ? row.getEntryAdapter().getSbn() : null; } else { sbn = row.getEntryLegacy().getSbn(); } return (sbn != null && sbn.getNotification().showsTime()); } /** * Reset the modifications to this row for removing it from the group. */ public void restoreChildNotification(ExpandableNotificationRow row) { for (int compI = 0; compI < mProcessors.size(); compI++) { mProcessors.get(compI).apply(row, true /* reset */); } sanitizeTopLineViews(row); } private static class Processor { private final int mId; private final DataExtractor mExtractor; private final ViewComparator mComparator; private final ResultApplicator mApplicator; private final ExpandableNotificationRow mParentRow; private boolean mApply; private View mParentView; private Object mParentData; public static Processor forTextView(ExpandableNotificationRow row, int id) { return new Processor(row, id, null, TEXT_VIEW_COMPARATOR, VISIBILITY_APPLICATOR); } Processor(ExpandableNotificationRow row, int id, DataExtractor extractor, ViewComparator comparator, ResultApplicator applicator) { mId = id; mExtractor = extractor; mApplicator = applicator; mComparator = comparator; mParentRow = row; } public void init() { NotificationViewWrapper wrapper = mParentRow.getNotificationViewWrapper(); View header = wrapper == null ? null : wrapper.getNotificationHeader(); mParentView = header == null ? null : header.findViewById(mId); mParentData = mExtractor == null ? null : mExtractor.extractData(mParentRow); mApply = !mComparator.isEmpty(mParentView); } public void compareToGroupParent(ExpandableNotificationRow row) { if (!mApply) { return; } View contractedChild = row.getPrivateLayout().getContractedChild(); if (contractedChild == null) { return; } View ownView = contractedChild.findViewById(mId); if (ownView == null) { // No view found. We still consider this to be the same to avoid weird flickering // when for example showing an undo notification return; } Object childData = mExtractor == null ? null : mExtractor.extractData(row); mApply = mComparator.compare(mParentView, ownView, mParentData, childData); } public void apply(ExpandableNotificationRow row) { apply(row, false /* reset */); } public void apply(ExpandableNotificationRow row, boolean reset) { boolean apply = mApply && !reset; if (row.isSummaryWithChildren()) { applyToView(apply, reset, row.getNotificationViewWrapper().getNotificationHeader()); return; } applyToView(apply, reset, row.getPrivateLayout().getContractedChild()); applyToView(apply, reset, row.getPrivateLayout().getHeadsUpChild()); applyToView(apply, reset, row.getPrivateLayout().getExpandedChild()); } private void applyToView(boolean apply, boolean reset, View parent) { if (parent != null) { View view = parent.findViewById(mId); if (view != null && !mComparator.isEmpty(view)) { mApplicator.apply(parent, view, apply, reset); } } } } private interface ViewComparator { /** * @param parent the view with the given id in the group header * @param child the view with the given id in the child notification * @param parentData optional data for the parent * @param childData optional data for the child * @return whether to views are the same */ boolean compare(View parent, View child, Object parentData, Object childData); boolean isEmpty(View view); } @VisibleForTesting interface DataExtractor { Object extractData(ExpandableNotificationRow row); } private static class BadgeComparator implements ViewComparator { @Override public boolean compare(View parent, View child, Object parentData, Object childData) { return parent.getVisibility() != View.GONE; } @Override public boolean isEmpty(View view) { if (AsyncGroupHeaderViewInflation.isEnabled() && view == null) { return true; } if (view instanceof ImageView) { return ((ImageView) view).getDrawable() == null; } return false; } } private static class TextViewComparator implements ViewComparator { @Override public boolean compare(View parent, View child, Object parentData, Object childData) { TextView parentView = (TextView) parent; CharSequence parentText = parentView == null ? "" : parentView.getText(); TextView childView = (TextView) child; CharSequence childText = childView == null ? "" : childView.getText(); return Objects.equals(parentText, childText); } @Override public boolean isEmpty(View view) { return view == null || TextUtils.isEmpty(((TextView) view).getText()); } } @VisibleForTesting static class IconComparator implements ViewComparator { @Override public boolean compare(View parent, View child, Object parentData, Object childData) { return false; } protected boolean hasSameIcon(Object parentData, Object childData) { if (parentData == null || childData == null) { return false; } Icon parentIcon = ((Notification) parentData).getSmallIcon(); Icon childIcon = ((Notification) childData).getSmallIcon(); return parentIcon.sameAs(childIcon); } /** * @return whether two ImageViews have the same colorFilterSet or none at all */ protected boolean hasSameColor(Object parentData, Object childData) { if ((parentData == null && childData != null) || (parentData != null && childData == null)) { return false; } int parentColor = ((Notification) parentData).color; int childColor = ((Notification) childData).color; return parentColor == childColor; } @Override public boolean isEmpty(View view) { return false; } } private interface ResultApplicator { /** * @param parent the root view of the child notification * @param view the view with the given id in the child notification * @param apply whether the state should be applied or removed * @param reset if [de]application is the result of a reset */ void apply(View parent, View view, boolean apply, boolean reset); } private static class VisibilityApplicator implements ResultApplicator { @Override public void apply(View parent, View view, boolean apply, boolean reset) { if (view != null) { view.setVisibility(apply ? View.GONE : View.VISIBLE); } } } private static class AppNameApplicator extends VisibilityApplicator { @Override public void apply(View parent, View view, boolean apply, boolean reset) { if (!notificationsRedesignTemplates() && reset && parent instanceof ConversationLayout) { ConversationLayout layout = (ConversationLayout) parent; apply = layout.shouldHideAppName(); } super.apply(parent, view, apply, reset); } } private static class AppNameComparator extends TextViewComparator { @Override public boolean compare(View parent, View child, Object parentData, Object childData) { if (isEmpty(child)) { // In headerless notifications the AppName view exists but is usually GONE (and not // populated). We need to treat this case as equal to the header in order to // deduplicate the view. return true; } return super.compare(parent, child, parentData, childData); } } private static class LeftIconApplicator implements ResultApplicator { public static final int[] MARGIN_ADJUSTED_VIEWS = { R.id.text, R.id.big_text, R.id.title, R.id.notification_main_column, R.id.notification_header}; @Override public void apply(View parent, View child, boolean showLeftIcon, boolean reset) { ImageView leftIcon = child.findViewById(com.android.internal.R.id.left_icon); if (leftIcon == null) { return; } ImageView rightIcon = child.findViewById(com.android.internal.R.id.right_icon); boolean keepRightIcon = rightIcon != null && Integer.valueOf(1).equals( rightIcon.getTag(R.id.tag_keep_when_showing_left_icon)); boolean leftIconUsesRightIconDrawable = Integer.valueOf(1).equals( leftIcon.getTag(R.id.tag_uses_right_icon_drawable)); if (leftIconUsesRightIconDrawable) { // Use the right drawable when showing the left, unless the right is being kept Drawable rightDrawable = rightIcon == null ? null : rightIcon.getDrawable(); leftIcon.setImageDrawable(showLeftIcon && !keepRightIcon ? rightDrawable : null); } leftIcon.setVisibility(showLeftIcon ? View.VISIBLE : View.GONE); // update the right icon as well if (rightIcon != null) { boolean showRightIcon = (keepRightIcon || !showLeftIcon) && rightIcon.getDrawable() != null; rightIcon.setVisibility(showRightIcon ? View.VISIBLE : View.GONE); for (int viewId : MARGIN_ADJUSTED_VIEWS) { adjustMargins(showRightIcon, child.findViewById(viewId)); } } } void adjustMargins(boolean iconVisible, View target) { if (target == null) { return; } if (target instanceof ImageFloatingTextView) { ((ImageFloatingTextView) target).setHasImage(iconVisible); return; } final Integer data = (Integer) target.getTag(iconVisible ? com.android.internal.R.id.tag_margin_end_when_icon_visible : com.android.internal.R.id.tag_margin_end_when_icon_gone); if (data == null) { return; } final DisplayMetrics metrics = target.getResources().getDisplayMetrics(); final int value = TypedValue.complexToDimensionPixelOffset(data, metrics); if (target instanceof NotificationHeaderView) { ((NotificationHeaderView) target).setTopLineExtraMarginEnd(value); } else { ViewGroup.LayoutParams layoutParams = target.getLayoutParams(); if (layoutParams instanceof ViewGroup.MarginLayoutParams) { ((ViewGroup.MarginLayoutParams) layoutParams).setMarginEnd(value); target.setLayoutParams(layoutParams); } } } } }