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