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