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