package com.android.car.notification; import android.animation.Animator; import android.animation.AnimatorInflater; import android.animation.AnimatorListenerAdapter; import android.animation.AnimatorSet; import android.animation.ObjectAnimator; import android.car.drivingstate.CarUxRestrictions; import android.car.drivingstate.CarUxRestrictionsManager; import android.content.Context; import android.content.Intent; import android.graphics.Rect; import android.os.Build; import android.os.Handler; import android.os.UserHandle; import android.provider.Settings; import android.util.AttributeSet; import android.util.Log; import android.view.KeyEvent; import android.view.View; import android.widget.Button; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.constraintlayout.widget.ConstraintLayout; import androidx.recyclerview.widget.DefaultItemAnimator; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView.OnScrollListener; import com.android.car.notification.template.GroupNotificationViewHolder; import com.android.car.uxr.UxrContentLimiterImpl; import com.android.internal.annotations.VisibleForTesting; import com.android.internal.statusbar.IStatusBarService; import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.TreeMap; /** * Layout that contains Car Notifications. * * It does some extra setup in the onFinishInflate method because it may not get used from an * activity where one would normally attach RecyclerViews */ public class CarNotificationView extends ConstraintLayout implements CarUxRestrictionsManager.OnUxRestrictionsChangedListener { public static final boolean DEBUG = Build.IS_DEBUGGABLE; public static final String TAG = "CarNotificationView"; private final boolean mCollapsePanelAfterManageButton; private CarNotificationViewAdapter mAdapter; private LinearLayoutManager mLayoutManager; private NotificationClickHandlerFactory mClickHandlerFactory; private NotificationDataManager mNotificationDataManager; private boolean mIsClearAllActive = false; private List mNotifications; private UxrContentLimiterImpl mUxrContentLimiter; private KeyEventHandler mKeyEventHandler; private RecyclerView mListView; private Button mManageButton; private TextView mEmptyNotificationHeaderText; private Button mClearAllButton; private CarNotificationItemTouchListener mItemTouchListener; private OnScrollListener mScrollListener; public CarNotificationView(Context context, AttributeSet attrs) { super(context, attrs); mNotificationDataManager = NotificationDataManager.getInstance(); mCollapsePanelAfterManageButton = context.getResources().getBoolean( R.bool.config_collapseShadePanelAfterManageButtonPress); } /** * Attaches the CarNotificationViewAdapter and CarNotificationItemTouchListener to the * notification list. */ @Override protected void onFinishInflate() { super.onFinishInflate(); mListView = findViewById(R.id.notifications); mListView.setClipChildren(false); Context context = getContext(); mLayoutManager = new LinearLayoutManager(context); mListView.setLayoutManager(mLayoutManager); mListView.addItemDecoration(new TopAndBottomOffsetDecoration( context.getResources().getDimensionPixelSize(R.dimen.item_spacing))); mListView.addItemDecoration(new ItemSpacingDecoration( context.getResources().getDimensionPixelSize(R.dimen.item_spacing))); mAdapter = new CarNotificationViewAdapter(context, /* isGroupNotificationAdapter= */ false, this::startClearAllNotifications); mUxrContentLimiter = new UxrContentLimiterImpl(context, R.xml.uxr_config); mEmptyNotificationHeaderText = findViewById(R.id.empty_notification_text); mManageButton = findViewById(R.id.manage_button); mClearAllButton = findViewById(R.id.clear_all_button); } @Override public void onAttachedToWindow() { super.onAttachedToWindow(); mUxrContentLimiter.setAdapter(mAdapter); mUxrContentLimiter.start(); mListView.setAdapter(mAdapter); mItemTouchListener = new CarNotificationItemTouchListener(getContext(), mAdapter); mListView.addOnItemTouchListener(mItemTouchListener); mScrollListener = new OnScrollListener() { @Override public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) { super.onScrollStateChanged(recyclerView, newState); // RecyclerView is not currently scrolling. if (newState == RecyclerView.SCROLL_STATE_IDLE) { setVisibleNotificationsAsSeen(); } } }; mListView.addOnScrollListener(mScrollListener); mListView.setItemAnimator(new DefaultItemAnimator(){ @Override public boolean animateChange(RecyclerView.ViewHolder oldHolder, RecyclerView.ViewHolder newHolder, int fromX, int fromY, int toX, int toY) { if (oldHolder == newHolder) { return animateMove(newHolder, fromX, fromY, toX, toY); } // return without animation to prevent flashing on notification update. dispatchChangeFinished(oldHolder, /* oldItem= */ true); dispatchChangeFinished(newHolder, /* oldItem= */ false); return true; } }); mManageButton.setOnClickListener(this::manageButtonOnClickListener); if (mClearAllButton != null) { mClearAllButton.setOnClickListener(v -> startClearAllNotifications()); } } @Override public void onDetachedFromWindow() { super.onDetachedFromWindow(); // TODO b/301492797 also set the adapter in the UxrContentLimiter to null mUxrContentLimiter.stop(); mListView.setAdapter(null); if (mItemTouchListener != null) { mListView.removeOnItemTouchListener(mItemTouchListener); } if (mScrollListener != null) { mListView.removeOnScrollListener(mScrollListener); } mListView.setItemAnimator(null); mManageButton.setOnClickListener(null); if (mClearAllButton != null) { mClearAllButton.setOnClickListener(null); } } @Override public boolean dispatchKeyEvent(KeyEvent event) { if (super.dispatchKeyEvent(event)) { return true; } if (mKeyEventHandler != null) { return mKeyEventHandler.dispatchKeyEvent(event); } return false; } @VisibleForTesting List getNotifications() { return mNotifications; } /** Sets a {@link KeyEventHandler} to help interact with the notification panel. */ public void setKeyEventHandler(KeyEventHandler keyEventHandler) { mKeyEventHandler = keyEventHandler; } /** * Updates notifications and update views. */ public void setNotifications(List notifications) { mNotifications = notifications; mAdapter.setNotifications(notifications, /* setRecyclerViewListHeaderAndFooter= */ true); refreshVisibility(); } /** * Removes notification from group list and updates views. */ public void removeNotification(AlertEntry alertEntry) { if (DEBUG) { Log.d(TAG, "Removing notification: " + alertEntry); } for (int i = 0; i < mNotifications.size(); i++) { NotificationGroup notificationGroup = new NotificationGroup(mNotifications.get(i)); boolean notificationRemoved = notificationGroup.removeNotification(alertEntry); if (notificationRemoved) { if (notificationGroup.getChildCount() == 0) { if (DEBUG) { Log.d(TAG, "Group deleted"); } mNotifications.remove(i); } else { if (DEBUG) { Log.d(TAG, "Edited notification group: " + notificationGroup); } mNotifications.set(i, notificationGroup); } break; } } mAdapter.setNotifications(mNotifications, /* setRecyclerViewListHeaderAndFooter= */ true); refreshVisibility(); } private void refreshVisibility() { if (mAdapter.hasNotifications()) { mEmptyNotificationHeaderText.setVisibility(View.GONE); mManageButton.setVisibility(View.GONE); } else { mEmptyNotificationHeaderText.setVisibility(View.VISIBLE); mManageButton.setVisibility(View.VISIBLE); } } /** * Collapses all expanded groups and empties notifications being cleared set. */ public void resetState() { mAdapter.collapseAllGroups(); for (int i = 0; i < mAdapter.getItemCount(); i++) { RecyclerView.ViewHolder holder = mListView.findViewHolderForAdapterPosition(i); if (holder != null && holder.getItemViewType() == NotificationViewType.GROUP) { GroupNotificationViewHolder groupNotificationViewHolder = (GroupNotificationViewHolder) holder; groupNotificationViewHolder.collapseGroup(); } } } @Override public void onUxRestrictionsChanged(CarUxRestrictions restrictionInfo) { mAdapter.setCarUxRestrictions(restrictionInfo); } /** * Sets the NotificationClickHandlerFactory that allows for a hook to run a block off code * when the notification is clicked. This is useful to dismiss a screen after * a notification list clicked. */ public void setClickHandlerFactory(NotificationClickHandlerFactory clickHandlerFactory) { mClickHandlerFactory = clickHandlerFactory; mAdapter.setClickHandlerFactory(clickHandlerFactory); } /** * A {@link RecyclerView.ItemDecoration} that will add a top offset to the first item and bottom * offset to the last item in the RecyclerView it is added to. */ private static class TopAndBottomOffsetDecoration extends RecyclerView.ItemDecoration { private int mTopAndBottomOffset; private TopAndBottomOffsetDecoration(int topOffset) { mTopAndBottomOffset = topOffset; } @Override public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) { super.getItemOffsets(outRect, view, parent, state); int position = parent.getChildAdapterPosition(view); if (position == 0) { outRect.top = mTopAndBottomOffset; } if (position == state.getItemCount() - 1) { outRect.bottom = mTopAndBottomOffset; } } } /** * Identifies dismissible notifications views and animates them out in the order * specified in config. Calls finishClearNotifications on animation end. */ private void startClearAllNotifications() { // Prevent invoking the click listeners again until the current clear all flow is complete. if (mIsClearAllActive) { return; } mIsClearAllActive = true; List dismissibleNotifications = getAllDismissibleNotifications(); List dismissibleNotificationViews = getNotificationViews(dismissibleNotifications); if (dismissibleNotificationViews.isEmpty()) { finishClearAllNotifications(dismissibleNotifications); return; } AnimatorSet animatorSet = createDismissAnimation(dismissibleNotificationViews); animatorSet.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animator) { finishClearAllNotifications(dismissibleNotifications); } }); animatorSet.start(); } /** * Returns a List of all Notification Groups that are dismissible. */ private List getAllDismissibleNotifications() { List notifications = new ArrayList<>(); mNotifications.forEach(notificationGroup -> { if (notificationGroup.isDismissible()) { notifications.add(notificationGroup); } }); return notifications; } /** * Returns the Views that are bound to the provided notifications, sorted so that their * positions are in the ascending order. * *

Note: Provided notifications might not have Views bound to them.

*/ private List getNotificationViews(List notifications) { Set notificationIds = new HashSet(); notifications.forEach(notificationGroup -> { long id = notificationGroup.isGroup() ? notificationGroup.getGroupKey().hashCode() : notificationGroup.getSingleNotification().getKey().hashCode(); notificationIds.add(id); }); TreeMap notificationViews = new TreeMap<>(); for (int i = 0; i < mListView.getChildCount(); i++) { View currentChildView = mListView.getChildAt(i); RecyclerView.ViewHolder holder = mListView.getChildViewHolder(currentChildView); int position = holder.getLayoutPosition(); if (notificationIds.contains(mAdapter.getItemId(position))) { notificationViews.put(position, currentChildView); } } List notificationViewsSorted = new ArrayList<>(notificationViews.values()); return notificationViewsSorted; } /** * Returns {@link AnimatorSet} for dismissing notifications from the clear all event. */ private AnimatorSet createDismissAnimation(List dismissibleNotificationViews) { ArrayList animators = new ArrayList<>(); boolean dismissFromBottomUp = getContext().getResources().getBoolean( R.bool.config_clearAllNotificationsAnimationFromBottomUp); int delayInterval = getContext().getResources().getInteger( R.integer.clear_all_notifications_animation_delay_interval_ms); for (int i = 0; i < dismissibleNotificationViews.size(); i++) { View currentView = dismissibleNotificationViews.get(i); ObjectAnimator animator = (ObjectAnimator) AnimatorInflater.loadAnimator(getContext(), R.animator.clear_all_animate_out); animator.setTarget(currentView); /* * Each animator is assigned a different start delay value in order to generate the * animation effect of dismissing notifications one by one. * Therefore, the delay calculation depends on whether the notifications are * dismissed from bottom up or from top down. */ int delayMultiplier = dismissFromBottomUp ? dismissibleNotificationViews.size() - i : i; int delay = delayInterval * delayMultiplier; animator.setStartDelay(delay); animators.add(animator); } ObjectAnimator[] animatorsArray = animators.toArray(new ObjectAnimator[animators.size()]); AnimatorSet animatorSet = new AnimatorSet(); animatorSet.playTogether(animatorsArray); return animatorSet; } /** * Clears the provided notifications with {@link IStatusBarService} and optionally collapses the * shade panel. */ private void finishClearAllNotifications(List dismissibleNotifications) { boolean collapsePanel = getContext().getResources().getBoolean( R.bool.config_collapseShadePanelAfterClearAllNotifications); int collapsePanelDelay = getContext().getResources().getInteger( R.integer.delay_between_clear_all_notifications_end_and_collapse_shade_panel_ms); mClickHandlerFactory.clearNotifications(dismissibleNotifications); if (collapsePanel) { Handler handler = getHandler(); if (handler != null) { handler.postDelayed(() -> { mClickHandlerFactory.collapsePanel(); }, collapsePanelDelay); } } mIsClearAllActive = false; } /** * A {@link RecyclerView.ItemDecoration} that will add spacing between each item in the * RecyclerView that it is added to. */ private static class ItemSpacingDecoration extends RecyclerView.ItemDecoration { private int mItemSpacing; private ItemSpacingDecoration(int itemSpacing) { mItemSpacing = itemSpacing; } @Override public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) { super.getItemOffsets(outRect, view, parent, state); int position = parent.getChildAdapterPosition(view); // Skip offset for last item. if (position == state.getItemCount() - 1) { return; } outRect.bottom = mItemSpacing; } } /** * Sets currently visible notifications as "seen". */ public void setVisibleNotificationsAsSeen() { int firstVisible = mLayoutManager.findFirstVisibleItemPosition(); int lastVisible = mLayoutManager.findLastVisibleItemPosition(); // No visible items are found. if (firstVisible == RecyclerView.NO_POSITION) return; mAdapter.setVisibleNotificationsAsSeen(firstVisible, lastVisible); } private void manageButtonOnClickListener(View v) { Intent intent = new Intent(Settings.ACTION_NOTIFICATION_SETTINGS); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_MULTIPLE_TASK); intent.addCategory(Intent.CATEGORY_DEFAULT); getContext().startActivityAsUser(intent, UserHandle.of(NotificationUtils.getCurrentUser(getContext()))); if (mClickHandlerFactory != null && mCollapsePanelAfterManageButton) { mClickHandlerFactory.collapsePanel(); } } /** An interface to help interact with the notification panel. */ public interface KeyEventHandler { /** Allows handling of a {@link KeyEvent} if it isn't already handled by the superclass. */ boolean dispatchKeyEvent(KeyEvent event); } @VisibleForTesting void setAdapter(CarNotificationViewAdapter adapter) { mAdapter = adapter; } @VisibleForTesting void setListView(RecyclerView listView) { mListView = listView; } }