1 package com.android.car.notification;
2 
3 import android.animation.Animator;
4 import android.animation.AnimatorInflater;
5 import android.animation.AnimatorListenerAdapter;
6 import android.animation.AnimatorSet;
7 import android.animation.ObjectAnimator;
8 import android.car.drivingstate.CarUxRestrictions;
9 import android.car.drivingstate.CarUxRestrictionsManager;
10 import android.content.Context;
11 import android.content.Intent;
12 import android.graphics.Rect;
13 import android.os.Build;
14 import android.os.Handler;
15 import android.os.UserHandle;
16 import android.provider.Settings;
17 import android.util.AttributeSet;
18 import android.util.Log;
19 import android.view.KeyEvent;
20 import android.view.View;
21 import android.widget.Button;
22 import android.widget.TextView;
23 
24 import androidx.annotation.NonNull;
25 import androidx.constraintlayout.widget.ConstraintLayout;
26 import androidx.recyclerview.widget.DefaultItemAnimator;
27 import androidx.recyclerview.widget.LinearLayoutManager;
28 import androidx.recyclerview.widget.RecyclerView;
29 import androidx.recyclerview.widget.RecyclerView.OnScrollListener;
30 
31 import com.android.car.notification.template.GroupNotificationViewHolder;
32 import com.android.car.uxr.UxrContentLimiterImpl;
33 import com.android.internal.annotations.VisibleForTesting;
34 import com.android.internal.statusbar.IStatusBarService;
35 
36 import java.util.ArrayList;
37 import java.util.HashSet;
38 import java.util.List;
39 import java.util.Set;
40 import java.util.TreeMap;
41 
42 
43 /**
44  * Layout that contains Car Notifications.
45  *
46  * It does some extra setup in the onFinishInflate method because it may not get used from an
47  * activity where one would normally attach RecyclerViews
48  */
49 public class CarNotificationView extends ConstraintLayout
50         implements CarUxRestrictionsManager.OnUxRestrictionsChangedListener {
51     public static final boolean DEBUG = Build.IS_DEBUGGABLE;
52     public static final String TAG = "CarNotificationView";
53 
54     private final boolean mCollapsePanelAfterManageButton;
55 
56     private CarNotificationViewAdapter mAdapter;
57     private LinearLayoutManager mLayoutManager;
58     private NotificationClickHandlerFactory mClickHandlerFactory;
59     private NotificationDataManager mNotificationDataManager;
60     private boolean mIsClearAllActive = false;
61     private List<NotificationGroup> mNotifications;
62     private UxrContentLimiterImpl mUxrContentLimiter;
63     private KeyEventHandler mKeyEventHandler;
64     private RecyclerView mListView;
65     private Button mManageButton;
66     private TextView mEmptyNotificationHeaderText;
67     private Button mClearAllButton;
68     private CarNotificationItemTouchListener mItemTouchListener;
69     private OnScrollListener mScrollListener;
70 
CarNotificationView(Context context, AttributeSet attrs)71     public CarNotificationView(Context context, AttributeSet attrs) {
72         super(context, attrs);
73         mNotificationDataManager = NotificationDataManager.getInstance();
74         mCollapsePanelAfterManageButton = context.getResources().getBoolean(
75                 R.bool.config_collapseShadePanelAfterManageButtonPress);
76     }
77 
78     /**
79      * Attaches the CarNotificationViewAdapter and CarNotificationItemTouchListener to the
80      * notification list.
81      */
82     @Override
onFinishInflate()83     protected void onFinishInflate() {
84         super.onFinishInflate();
85         mListView = findViewById(R.id.notifications);
86 
87         mListView.setClipChildren(false);
88         Context context = getContext();
89         mLayoutManager = new LinearLayoutManager(context);
90         mListView.setLayoutManager(mLayoutManager);
91         mListView.addItemDecoration(new TopAndBottomOffsetDecoration(
92                 context.getResources().getDimensionPixelSize(R.dimen.item_spacing)));
93         mListView.addItemDecoration(new ItemSpacingDecoration(
94                 context.getResources().getDimensionPixelSize(R.dimen.item_spacing)));
95         mAdapter = new CarNotificationViewAdapter(context, /* isGroupNotificationAdapter= */
96                 false, this::startClearAllNotifications);
97 
98         mUxrContentLimiter = new UxrContentLimiterImpl(context, R.xml.uxr_config);
99 
100         mEmptyNotificationHeaderText = findViewById(R.id.empty_notification_text);
101         mManageButton = findViewById(R.id.manage_button);
102 
103         mClearAllButton = findViewById(R.id.clear_all_button);
104     }
105 
106     @Override
onAttachedToWindow()107     public void onAttachedToWindow() {
108         super.onAttachedToWindow();
109 
110         mUxrContentLimiter.setAdapter(mAdapter);
111         mUxrContentLimiter.start();
112         mListView.setAdapter(mAdapter);
113 
114         mItemTouchListener = new CarNotificationItemTouchListener(getContext(), mAdapter);
115         mListView.addOnItemTouchListener(mItemTouchListener);
116 
117         mScrollListener = new OnScrollListener() {
118             @Override
119             public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {
120                 super.onScrollStateChanged(recyclerView, newState);
121                 // RecyclerView is not currently scrolling.
122                 if (newState == RecyclerView.SCROLL_STATE_IDLE) {
123                     setVisibleNotificationsAsSeen();
124                 }
125             }
126         };
127         mListView.addOnScrollListener(mScrollListener);
128 
129         mListView.setItemAnimator(new DefaultItemAnimator(){
130             @Override
131             public boolean animateChange(RecyclerView.ViewHolder oldHolder, RecyclerView.ViewHolder
132                     newHolder, int fromX, int fromY, int toX, int toY) {
133                 if (oldHolder == newHolder) {
134                     return animateMove(newHolder, fromX, fromY, toX, toY);
135                 }
136                 // return without animation to prevent flashing on notification update.
137                 dispatchChangeFinished(oldHolder, /* oldItem= */ true);
138                 dispatchChangeFinished(newHolder, /* oldItem= */ false);
139                 return true;
140             }
141         });
142 
143         mManageButton.setOnClickListener(this::manageButtonOnClickListener);
144         if (mClearAllButton != null) {
145             mClearAllButton.setOnClickListener(v -> startClearAllNotifications());
146         }
147     }
148 
149     @Override
onDetachedFromWindow()150     public void onDetachedFromWindow() {
151         super.onDetachedFromWindow();
152 
153         // TODO b/301492797 also set the adapter in the UxrContentLimiter to null
154         mUxrContentLimiter.stop();
155 
156         mListView.setAdapter(null);
157 
158         if (mItemTouchListener != null) {
159             mListView.removeOnItemTouchListener(mItemTouchListener);
160         }
161         if (mScrollListener != null) {
162             mListView.removeOnScrollListener(mScrollListener);
163         }
164         mListView.setItemAnimator(null);
165         mManageButton.setOnClickListener(null);
166 
167         if (mClearAllButton != null) {
168             mClearAllButton.setOnClickListener(null);
169         }
170     }
171 
172     @Override
dispatchKeyEvent(KeyEvent event)173     public boolean dispatchKeyEvent(KeyEvent event) {
174         if (super.dispatchKeyEvent(event)) {
175             return true;
176         }
177 
178         if (mKeyEventHandler != null) {
179             return mKeyEventHandler.dispatchKeyEvent(event);
180         }
181 
182         return false;
183     }
184 
185     @VisibleForTesting
getNotifications()186     List<NotificationGroup> getNotifications() {
187         return mNotifications;
188     }
189 
190     /** Sets a {@link KeyEventHandler} to help interact with the notification panel. */
setKeyEventHandler(KeyEventHandler keyEventHandler)191     public void setKeyEventHandler(KeyEventHandler keyEventHandler) {
192         mKeyEventHandler = keyEventHandler;
193     }
194 
195     /**
196      * Updates notifications and update views.
197      */
setNotifications(List<NotificationGroup> notifications)198     public void setNotifications(List<NotificationGroup> notifications) {
199         mNotifications = notifications;
200         mAdapter.setNotifications(notifications, /* setRecyclerViewListHeaderAndFooter= */ true);
201         refreshVisibility();
202     }
203 
204     /**
205      * Removes notification from group list and updates views.
206      */
removeNotification(AlertEntry alertEntry)207     public void removeNotification(AlertEntry alertEntry) {
208         if (DEBUG) {
209             Log.d(TAG, "Removing notification: " + alertEntry);
210         }
211 
212         for (int i = 0; i < mNotifications.size(); i++) {
213             NotificationGroup notificationGroup = new NotificationGroup(mNotifications.get(i));
214             boolean notificationRemoved = notificationGroup.removeNotification(alertEntry);
215             if (notificationRemoved) {
216                 if (notificationGroup.getChildCount() == 0) {
217                     if (DEBUG) {
218                         Log.d(TAG, "Group deleted");
219                     }
220                     mNotifications.remove(i);
221                 } else {
222                     if (DEBUG) {
223                         Log.d(TAG, "Edited notification group: " + notificationGroup);
224                     }
225                     mNotifications.set(i, notificationGroup);
226                 }
227                 break;
228             }
229         }
230 
231         mAdapter.setNotifications(mNotifications, /* setRecyclerViewListHeaderAndFooter= */ true);
232         refreshVisibility();
233     }
234 
refreshVisibility()235     private void refreshVisibility() {
236         if (mAdapter.hasNotifications()) {
237             mEmptyNotificationHeaderText.setVisibility(View.GONE);
238             mManageButton.setVisibility(View.GONE);
239         } else {
240             mEmptyNotificationHeaderText.setVisibility(View.VISIBLE);
241             mManageButton.setVisibility(View.VISIBLE);
242         }
243     }
244 
245     /**
246      * Collapses all expanded groups and empties notifications being cleared set.
247      */
resetState()248     public void resetState() {
249         mAdapter.collapseAllGroups();
250         for (int i = 0; i < mAdapter.getItemCount(); i++) {
251             RecyclerView.ViewHolder holder = mListView.findViewHolderForAdapterPosition(i);
252             if (holder != null && holder.getItemViewType() == NotificationViewType.GROUP) {
253                 GroupNotificationViewHolder groupNotificationViewHolder =
254                         (GroupNotificationViewHolder) holder;
255                 groupNotificationViewHolder.collapseGroup();
256             }
257         }
258     }
259 
260     @Override
onUxRestrictionsChanged(CarUxRestrictions restrictionInfo)261     public void onUxRestrictionsChanged(CarUxRestrictions restrictionInfo) {
262         mAdapter.setCarUxRestrictions(restrictionInfo);
263     }
264 
265     /**
266      * Sets the NotificationClickHandlerFactory that allows for a hook to run a block off code
267      * when  the notification is clicked. This is useful to dismiss a screen after
268      * a notification list clicked.
269      */
setClickHandlerFactory(NotificationClickHandlerFactory clickHandlerFactory)270     public void setClickHandlerFactory(NotificationClickHandlerFactory clickHandlerFactory) {
271         mClickHandlerFactory = clickHandlerFactory;
272         mAdapter.setClickHandlerFactory(clickHandlerFactory);
273     }
274 
275     /**
276      * A {@link RecyclerView.ItemDecoration} that will add a top offset to the first item and bottom
277      * offset to the last item in the RecyclerView it is added to.
278      */
279     private static class TopAndBottomOffsetDecoration extends RecyclerView.ItemDecoration {
280         private int mTopAndBottomOffset;
281 
TopAndBottomOffsetDecoration(int topOffset)282         private TopAndBottomOffsetDecoration(int topOffset) {
283             mTopAndBottomOffset = topOffset;
284         }
285 
286         @Override
getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state)287         public void getItemOffsets(Rect outRect, View view, RecyclerView parent,
288                 RecyclerView.State state) {
289             super.getItemOffsets(outRect, view, parent, state);
290             int position = parent.getChildAdapterPosition(view);
291 
292             if (position == 0) {
293                 outRect.top = mTopAndBottomOffset;
294             }
295             if (position == state.getItemCount() - 1) {
296                 outRect.bottom = mTopAndBottomOffset;
297             }
298         }
299     }
300 
301     /**
302      * Identifies dismissible notifications views and animates them out in the order
303      * specified in config. Calls finishClearNotifications on animation end.
304      */
startClearAllNotifications()305     private void startClearAllNotifications() {
306         // Prevent invoking the click listeners again until the current clear all flow is complete.
307         if (mIsClearAllActive) {
308             return;
309         }
310         mIsClearAllActive = true;
311 
312         List<NotificationGroup> dismissibleNotifications = getAllDismissibleNotifications();
313         List<View> dismissibleNotificationViews = getNotificationViews(dismissibleNotifications);
314 
315         if (dismissibleNotificationViews.isEmpty()) {
316             finishClearAllNotifications(dismissibleNotifications);
317             return;
318         }
319 
320         AnimatorSet animatorSet = createDismissAnimation(dismissibleNotificationViews);
321         animatorSet.addListener(new AnimatorListenerAdapter() {
322             @Override
323             public void onAnimationEnd(Animator animator) {
324                 finishClearAllNotifications(dismissibleNotifications);
325             }
326         });
327         animatorSet.start();
328     }
329 
330     /**
331      * Returns a List of all Notification Groups that are dismissible.
332      */
getAllDismissibleNotifications()333     private List<NotificationGroup> getAllDismissibleNotifications() {
334         List<NotificationGroup> notifications = new ArrayList<>();
335         mNotifications.forEach(notificationGroup -> {
336             if (notificationGroup.isDismissible()) {
337                 notifications.add(notificationGroup);
338             }
339         });
340         return notifications;
341     }
342 
343     /**
344      * Returns the Views that are bound to the provided notifications, sorted so that their
345      * positions are in the ascending order.
346      *
347      * <p>Note: Provided notifications might not have Views bound to them.</p>
348      */
getNotificationViews(List<NotificationGroup> notifications)349     private List<View> getNotificationViews(List<NotificationGroup> notifications) {
350         Set notificationIds = new HashSet();
351         notifications.forEach(notificationGroup -> {
352             long id = notificationGroup.isGroup() ? notificationGroup.getGroupKey().hashCode() :
353                     notificationGroup.getSingleNotification().getKey().hashCode();
354             notificationIds.add(id);
355         });
356 
357         TreeMap<Integer, View> notificationViews = new TreeMap<>();
358         for (int i = 0; i < mListView.getChildCount(); i++) {
359             View currentChildView = mListView.getChildAt(i);
360             RecyclerView.ViewHolder holder = mListView.getChildViewHolder(currentChildView);
361             int position = holder.getLayoutPosition();
362             if (notificationIds.contains(mAdapter.getItemId(position))) {
363                 notificationViews.put(position, currentChildView);
364             }
365         }
366         List<View> notificationViewsSorted = new ArrayList<>(notificationViews.values());
367 
368         return notificationViewsSorted;
369     }
370 
371     /**
372      * Returns {@link AnimatorSet} for dismissing notifications from the clear all event.
373      */
createDismissAnimation(List<View> dismissibleNotificationViews)374     private AnimatorSet createDismissAnimation(List<View> dismissibleNotificationViews) {
375         ArrayList<Animator> animators = new ArrayList<>();
376         boolean dismissFromBottomUp = getContext().getResources().getBoolean(
377                 R.bool.config_clearAllNotificationsAnimationFromBottomUp);
378         int delayInterval = getContext().getResources().getInteger(
379                 R.integer.clear_all_notifications_animation_delay_interval_ms);
380         for (int i = 0; i < dismissibleNotificationViews.size(); i++) {
381             View currentView = dismissibleNotificationViews.get(i);
382             ObjectAnimator animator = (ObjectAnimator) AnimatorInflater.loadAnimator(getContext(),
383                     R.animator.clear_all_animate_out);
384             animator.setTarget(currentView);
385 
386             /*
387              * Each animator is assigned a different start delay value in order to generate the
388              * animation effect of dismissing notifications one by one.
389              * Therefore, the delay calculation depends on whether the notifications are
390              * dismissed from bottom up or from top down.
391              */
392             int delayMultiplier = dismissFromBottomUp ? dismissibleNotificationViews.size() - i : i;
393             int delay = delayInterval * delayMultiplier;
394 
395             animator.setStartDelay(delay);
396             animators.add(animator);
397         }
398         ObjectAnimator[] animatorsArray = animators.toArray(new ObjectAnimator[animators.size()]);
399 
400         AnimatorSet animatorSet = new AnimatorSet();
401         animatorSet.playTogether(animatorsArray);
402 
403         return animatorSet;
404     }
405 
406     /**
407      * Clears the provided notifications with {@link IStatusBarService} and optionally collapses the
408      * shade panel.
409      */
finishClearAllNotifications(List<NotificationGroup> dismissibleNotifications)410     private void finishClearAllNotifications(List<NotificationGroup> dismissibleNotifications) {
411         boolean collapsePanel = getContext().getResources().getBoolean(
412                 R.bool.config_collapseShadePanelAfterClearAllNotifications);
413         int collapsePanelDelay = getContext().getResources().getInteger(
414                 R.integer.delay_between_clear_all_notifications_end_and_collapse_shade_panel_ms);
415 
416         mClickHandlerFactory.clearNotifications(dismissibleNotifications);
417 
418         if (collapsePanel) {
419             Handler handler = getHandler();
420             if (handler != null) {
421                 handler.postDelayed(() -> {
422                     mClickHandlerFactory.collapsePanel();
423                 }, collapsePanelDelay);
424             }
425         }
426 
427         mIsClearAllActive = false;
428     }
429 
430     /**
431      * A {@link RecyclerView.ItemDecoration} that will add spacing between each item in the
432      * RecyclerView that it is added to.
433      */
434     private static class ItemSpacingDecoration extends RecyclerView.ItemDecoration {
435         private int mItemSpacing;
436 
ItemSpacingDecoration(int itemSpacing)437         private ItemSpacingDecoration(int itemSpacing) {
438             mItemSpacing = itemSpacing;
439         }
440 
441         @Override
getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state)442         public void getItemOffsets(Rect outRect, View view, RecyclerView parent,
443                 RecyclerView.State state) {
444             super.getItemOffsets(outRect, view, parent, state);
445             int position = parent.getChildAdapterPosition(view);
446 
447             // Skip offset for last item.
448             if (position == state.getItemCount() - 1) {
449                 return;
450             }
451 
452             outRect.bottom = mItemSpacing;
453         }
454     }
455 
456     /**
457      * Sets currently visible notifications as "seen".
458      */
setVisibleNotificationsAsSeen()459     public void setVisibleNotificationsAsSeen() {
460         int firstVisible = mLayoutManager.findFirstVisibleItemPosition();
461         int lastVisible = mLayoutManager.findLastVisibleItemPosition();
462 
463         // No visible items are found.
464         if (firstVisible == RecyclerView.NO_POSITION) return;
465 
466         mAdapter.setVisibleNotificationsAsSeen(firstVisible, lastVisible);
467     }
468 
manageButtonOnClickListener(View v)469     private void manageButtonOnClickListener(View v) {
470         Intent intent = new Intent(Settings.ACTION_NOTIFICATION_SETTINGS);
471         intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK
472                 | Intent.FLAG_ACTIVITY_MULTIPLE_TASK);
473         intent.addCategory(Intent.CATEGORY_DEFAULT);
474         getContext().startActivityAsUser(intent,
475                 UserHandle.of(NotificationUtils.getCurrentUser(getContext())));
476 
477         if (mClickHandlerFactory != null && mCollapsePanelAfterManageButton) {
478             mClickHandlerFactory.collapsePanel();
479         }
480     }
481 
482     /** An interface to help interact with the notification panel. */
483     public interface KeyEventHandler {
484         /** Allows handling of a {@link KeyEvent} if it isn't already handled by the superclass. */
dispatchKeyEvent(KeyEvent event)485         boolean dispatchKeyEvent(KeyEvent event);
486     }
487 
488     @VisibleForTesting
setAdapter(CarNotificationViewAdapter adapter)489     void setAdapter(CarNotificationViewAdapter adapter) {
490         mAdapter = adapter;
491     }
492 
493     @VisibleForTesting
setListView(RecyclerView listView)494     void setListView(RecyclerView listView) {
495         mListView = listView;
496     }
497 }
498