blob: 143fc324f8c776cd754a433e6e1c4ead8dc525ea [file] [log] [blame]
/*
* 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 android.app.Notification;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.Icon;
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 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.row.ExpandableNotificationRow;
import com.android.systemui.statusbar.notification.row.NotificationContentView;
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();
private static final DataExtractor ICON_EXTRACTOR = new DataExtractor() {
@Override
public Object extractData(ExpandableNotificationRow row) {
return row.getEntry().getSbn().getNotification();
}
};
private static final IconComparator ICON_VISIBILITY_COMPARATOR = new IconComparator() {
public boolean compare(View parent, View child, Object parentData,
Object childData) {
return hasSameIcon(parentData, childData)
&& hasSameColor(parentData, childData);
}
};
private static final IconComparator GREY_COMPARATOR = new IconComparator() {
public boolean compare(View parent, View child, Object parentData,
Object childData) {
return !hasSameIcon(parentData, childData)
|| hasSameColor(parentData, childData);
}
};
private static final ResultApplicator GREY_APPLICATOR = new ResultApplicator() {
@Override
public void apply(View parent, View view, boolean apply, boolean reset) {
CachingIconView icon = view.findViewById(com.android.internal.R.id.icon);
if (icon != null) {
icon.setGrayedOut(apply);
}
}
};
private final ExpandableNotificationRow mRow;
private final ArrayList<Processor> mProcessors = new ArrayList<>();
private final HashSet<Integer> mDividers = new HashSet<>();
public NotificationGroupingUtil(ExpandableNotificationRow row) {
mRow = row;
// 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,
ICON_VISIBILITY_COMPARATOR,
VISIBILITY_APPLICATOR));
// To grey them out the icons and expand button when the icons are not the same
mProcessors.add(new Processor(mRow,
com.android.internal.R.id.status_bar_latest_event_content,
ICON_EXTRACTOR,
GREY_COMPARATOR,
GREY_APPLICATOR));
mProcessors.add(new Processor(mRow,
com.android.internal.R.id.status_bar_latest_event_content,
ICON_EXTRACTOR,
ICON_VISIBILITY_COMPARATOR,
LEFT_ICON_APPLICATOR));
mProcessors.add(new Processor(mRow,
com.android.internal.R.id.profile_badge,
null /* Extractor */,
BADGE_COMPARATOR,
VISIBILITY_APPLICATOR));
mProcessors.add(new Processor(mRow,
com.android.internal.R.id.app_name_text,
null,
APP_NAME_COMPARATOR,
APP_NAME_APPLICATOR));
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<ExpandableNotificationRow> 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
|| row.getEntry().getSbn().getNotification().showsTime()
? 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;
}
}
}
/**
* 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() {
View header = mParentRow.getNotificationViewWrapper().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);
}
private 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 (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());
}
}
private abstract 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) {
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) {
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 (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);
}
}
}
}
}