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