1 /* 2 * Copyright (C) 2018 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 package com.android.car.notification; 17 18 import android.annotation.NonNull; 19 import android.app.Notification; 20 import android.car.drivingstate.CarUxRestrictions; 21 import android.content.Context; 22 import android.os.Bundle; 23 import android.util.Log; 24 import android.view.LayoutInflater; 25 import android.view.View; 26 import android.view.ViewGroup; 27 28 import androidx.annotation.Nullable; 29 import androidx.recyclerview.widget.DiffUtil; 30 import androidx.recyclerview.widget.LinearLayoutManager; 31 import androidx.recyclerview.widget.RecyclerView; 32 33 import com.android.car.notification.template.CarNotificationBaseViewHolder; 34 import com.android.car.notification.template.CarNotificationFooterViewHolder; 35 import com.android.car.notification.template.CarNotificationHeaderViewHolder; 36 import com.android.car.notification.template.GroupNotificationViewHolder; 37 import com.android.car.notification.template.GroupSummaryNotificationViewHolder; 38 import com.android.car.notification.template.MessageNotificationViewHolder; 39 import com.android.car.ui.recyclerview.ContentLimitingAdapter; 40 41 import java.util.ArrayList; 42 import java.util.HashSet; 43 import java.util.List; 44 import java.util.Set; 45 46 /** 47 * Notification data adapter that binds a notification to the corresponding view. 48 */ 49 public class CarNotificationViewAdapter extends ContentLimitingAdapter<RecyclerView.ViewHolder> 50 implements PreprocessingManager.CallStateListener { 51 private static final String TAG = "CarNotificationAdapter"; 52 53 private final Context mContext; 54 private final LayoutInflater mInflater; 55 private final int mMaxNumberGroupChildrenShown; 56 private final boolean mIsGroupNotificationAdapter; 57 58 // book keeping expanded notification groups 59 private final List<String> mExpandedNotifications = new ArrayList<>(); 60 private final CarNotificationItemController mNotificationItemController; 61 62 private List<NotificationGroup> mNotifications = new ArrayList<>(); 63 private LinearLayoutManager mLayoutManager; 64 private RecyclerView.RecycledViewPool mViewPool; 65 private CarUxRestrictions mCarUxRestrictions; 66 private NotificationClickHandlerFactory mClickHandlerFactory; 67 private NotificationDataManager mNotificationDataManager; 68 private boolean mIsInCall; 69 // Suppress binding views to child notifications in the process of being removed. 70 private Set<AlertEntry> mChildNotificationsBeingCleared = new HashSet<>(); 71 private boolean mHasHeaderAndFooter; 72 private int mMaxItems = ContentLimitingAdapter.UNLIMITED; 73 74 /** 75 * Constructor for a notification adapter. 76 * Can be used both by the root notification list view, or a grouped notification view. 77 * 78 * @param context the context for resources and inflating views 79 * @param isGroupNotificationAdapter true if this adapter is used by a grouped notification view 80 * @param notificationItemController shared logic to control notification items. 81 */ CarNotificationViewAdapter(Context context, boolean isGroupNotificationAdapter, @Nullable CarNotificationItemController notificationItemController)82 public CarNotificationViewAdapter(Context context, boolean isGroupNotificationAdapter, 83 @Nullable CarNotificationItemController notificationItemController) { 84 mContext = context; 85 mInflater = LayoutInflater.from(context); 86 mMaxNumberGroupChildrenShown = 87 mContext.getResources().getInteger(R.integer.max_group_children_number); 88 mIsGroupNotificationAdapter = isGroupNotificationAdapter; 89 mNotificationItemController = notificationItemController; 90 setHasStableIds(true); 91 if (!mIsGroupNotificationAdapter) { 92 mViewPool = new RecyclerView.RecycledViewPool(); 93 } 94 95 PreprocessingManager.getInstance(context).addCallStateListener(this::onCallStateChanged); 96 } 97 98 @Override onAttachedToRecyclerView(@onNull RecyclerView recyclerView)99 public void onAttachedToRecyclerView(@NonNull RecyclerView recyclerView) { 100 super.onAttachedToRecyclerView(recyclerView); 101 mLayoutManager = (LinearLayoutManager) recyclerView.getLayoutManager(); 102 } 103 104 @Override onDetachedFromRecyclerView(@onNull RecyclerView recyclerView)105 public void onDetachedFromRecyclerView(@NonNull RecyclerView recyclerView) { 106 super.onDetachedFromRecyclerView(recyclerView); 107 mLayoutManager = null; 108 } 109 110 @Override onCreateViewHolderImpl(@onNull ViewGroup parent, int viewType)111 public RecyclerView.ViewHolder onCreateViewHolderImpl(@NonNull ViewGroup parent, int viewType) { 112 RecyclerView.ViewHolder viewHolder; 113 View view; 114 switch (viewType) { 115 case NotificationViewType.HEADER: 116 view = mInflater.inflate(R.layout.notification_header_template, parent, false); 117 viewHolder = new CarNotificationHeaderViewHolder(mContext, view, 118 mNotificationItemController); 119 break; 120 case NotificationViewType.FOOTER: 121 view = mInflater.inflate(R.layout.notification_footer_template, parent, false); 122 viewHolder = new CarNotificationFooterViewHolder(mContext, view, 123 mNotificationItemController); 124 break; 125 default: 126 CarNotificationTypeItem carNotificationTypeItem = CarNotificationTypeItem.of( 127 viewType); 128 view = mInflater.inflate( 129 carNotificationTypeItem.getNotificationCenterTemplate(), parent, false); 130 viewHolder = carNotificationTypeItem.getViewHolder(view, mClickHandlerFactory); 131 } 132 133 return viewHolder; 134 } 135 136 @Override onBindViewHolderImpl(RecyclerView.ViewHolder holder, int position)137 public void onBindViewHolderImpl(RecyclerView.ViewHolder holder, int position) { 138 NotificationGroup notificationGroup = mNotifications.get(position); 139 int viewType = holder.getItemViewType(); 140 switch (viewType) { 141 case NotificationViewType.HEADER: 142 ((CarNotificationHeaderViewHolder) holder).bind(hasNotifications()); 143 return; 144 case NotificationViewType.FOOTER: 145 ((CarNotificationFooterViewHolder) holder).bind(hasNotifications()); 146 return; 147 case NotificationViewType.GROUP_EXPANDED: 148 ((GroupNotificationViewHolder) holder) 149 .bind(notificationGroup, this, /* isExpanded= */ true); 150 return; 151 case NotificationViewType.GROUP_COLLAPSED: 152 ((GroupNotificationViewHolder) holder) 153 .bind(notificationGroup, this, /* isExpanded= */ false); 154 return; 155 case NotificationViewType.GROUP_SUMMARY: 156 ((CarNotificationBaseViewHolder) holder).setHideDismissButton(true); 157 ((GroupSummaryNotificationViewHolder) holder).bind(notificationGroup); 158 return; 159 } 160 161 CarNotificationTypeItem carNotificationTypeItem = CarNotificationTypeItem.of(viewType); 162 AlertEntry alertEntry = notificationGroup.getSingleNotification(); 163 164 if (shouldRestrictMessagePreview() && (viewType == NotificationViewType.MESSAGE 165 || viewType == NotificationViewType.MESSAGE_IN_GROUP)) { 166 ((MessageNotificationViewHolder) holder) 167 .bindRestricted(alertEntry, /* isInGroup= */ false, /* isHeadsUp= */false); 168 } else { 169 carNotificationTypeItem.bind(alertEntry, false, (CarNotificationBaseViewHolder) holder); 170 } 171 } 172 173 @Override getItemViewTypeImpl(int position)174 public int getItemViewTypeImpl(int position) { 175 NotificationGroup notificationGroup = mNotifications.get(position); 176 177 if (notificationGroup.isHeader()) { 178 return NotificationViewType.HEADER; 179 } 180 181 if (notificationGroup.isFooter()) { 182 return NotificationViewType.FOOTER; 183 } 184 185 if (notificationGroup.isGroup()) { 186 if (mExpandedNotifications.contains(notificationGroup.getGroupKey())) { 187 return NotificationViewType.GROUP_EXPANDED; 188 } else { 189 return NotificationViewType.GROUP_COLLAPSED; 190 } 191 } else if (mExpandedNotifications.contains(notificationGroup.getGroupKey())) { 192 // when there are 2 notifications left in the expanded notification and one of them is 193 // removed at that time the item type changes from group to normal and hence the 194 // notification should be removed from expanded notifications. 195 setExpanded(notificationGroup.getGroupKey(), false); 196 } 197 198 Notification notification = 199 notificationGroup.getSingleNotification().getNotification(); 200 Bundle extras = notification.extras; 201 202 String category = notification.category; 203 if (category != null) { 204 switch (category) { 205 case Notification.CATEGORY_CALL: 206 return NotificationViewType.CALL; 207 case Notification.CATEGORY_CAR_EMERGENCY: 208 return NotificationViewType.CAR_EMERGENCY; 209 case Notification.CATEGORY_CAR_WARNING: 210 return NotificationViewType.CAR_WARNING; 211 case Notification.CATEGORY_CAR_INFORMATION: 212 return mIsGroupNotificationAdapter 213 ? NotificationViewType.CAR_INFORMATION_IN_GROUP 214 : NotificationViewType.CAR_INFORMATION; 215 case Notification.CATEGORY_MESSAGE: 216 return mIsGroupNotificationAdapter 217 ? NotificationViewType.MESSAGE_IN_GROUP : NotificationViewType.MESSAGE; 218 default: 219 break; 220 } 221 } 222 223 // progress 224 int progressMax = extras.getInt(Notification.EXTRA_PROGRESS_MAX); 225 boolean isIndeterminate = extras.getBoolean( 226 Notification.EXTRA_PROGRESS_INDETERMINATE); 227 boolean hasValidProgress = isIndeterminate || progressMax != 0; 228 boolean isProgress = extras.containsKey(Notification.EXTRA_PROGRESS) 229 && extras.containsKey(Notification.EXTRA_PROGRESS_MAX) 230 && hasValidProgress 231 && !notification.hasCompletedProgress(); 232 if (isProgress) { 233 return mIsGroupNotificationAdapter 234 ? NotificationViewType.PROGRESS_IN_GROUP : NotificationViewType.PROGRESS; 235 } 236 237 // inbox 238 boolean isInbox = extras.containsKey(Notification.EXTRA_TITLE_BIG) 239 && extras.containsKey(Notification.EXTRA_SUMMARY_TEXT); 240 if (isInbox) { 241 return mIsGroupNotificationAdapter 242 ? NotificationViewType.INBOX_IN_GROUP : NotificationViewType.INBOX; 243 } 244 245 // group summary 246 boolean isGroupSummary = notificationGroup.getChildTitles() != null; 247 if (isGroupSummary) { 248 return NotificationViewType.GROUP_SUMMARY; 249 } 250 251 // the big text and big picture styles are fallen back to basic template in car 252 // i.e. setting the big text and big picture does not have an effect 253 boolean isBigText = extras.containsKey(Notification.EXTRA_BIG_TEXT); 254 if (isBigText) { 255 Log.i(TAG, "Big text style is not supported as a car notification"); 256 } 257 boolean isBigPicture = extras.containsKey(Notification.EXTRA_PICTURE); 258 if (isBigPicture) { 259 Log.i(TAG, "Big picture style is not supported as a car notification"); 260 } 261 262 // basic, big text, big picture 263 return mIsGroupNotificationAdapter 264 ? NotificationViewType.BASIC_IN_GROUP : NotificationViewType.BASIC; 265 } 266 267 @Override getUnrestrictedItemCount()268 public int getUnrestrictedItemCount() { 269 return mNotifications.size(); 270 } 271 272 @Override setMaxItems(int maxItems)273 public void setMaxItems(int maxItems) { 274 if (maxItems == ContentLimitingAdapter.UNLIMITED || !mHasHeaderAndFooter) { 275 mMaxItems = maxItems; 276 } else { 277 // Adding one so the notification header doesn't count toward the limit. 278 mMaxItems = maxItems + 1; 279 } 280 super.setMaxItems(mMaxItems); 281 } 282 283 @Override getScrollToPositionWhenRestricted()284 protected int getScrollToPositionWhenRestricted() { 285 if (mLayoutManager == null) { 286 return -1; 287 } 288 int firstItem = mLayoutManager.findFirstVisibleItemPosition(); 289 if (firstItem >= getItemCount() - 1) { 290 return getItemCount() - 1; 291 } 292 return -1; 293 } 294 295 @Override getItemId(int position)296 public long getItemId(int position) { 297 NotificationGroup notificationGroup = mNotifications.get(position); 298 if (notificationGroup.isHeader()) { 299 return 0; 300 } 301 302 if (notificationGroup.isFooter()) { 303 return 1; 304 } 305 306 return notificationGroup.isGroup() 307 ? notificationGroup.getGroupKey().hashCode() 308 : notificationGroup.getSingleNotification().getKey().hashCode(); 309 } 310 311 /** 312 * Set the expansion state of a group notification given its group key. 313 * 314 * @param groupKey the unique identifier of a {@link NotificationGroup} 315 * @param isExpanded whether the group notification should be expanded. 316 */ setExpanded(String groupKey, boolean isExpanded)317 public void setExpanded(String groupKey, boolean isExpanded) { 318 if (isExpanded(groupKey) == isExpanded) { 319 return; 320 } 321 322 if (isExpanded) { 323 mExpandedNotifications.add(groupKey); 324 } else { 325 mExpandedNotifications.remove(groupKey); 326 } 327 } 328 329 /** 330 * Collapses all expanded groups. 331 */ collapseAllGroups()332 public void collapseAllGroups() { 333 if (!mExpandedNotifications.isEmpty()) { 334 mExpandedNotifications.clear(); 335 } 336 } 337 338 /** 339 * Returns whether the notification is expanded given its group key. 340 */ isExpanded(String groupKey)341 boolean isExpanded(String groupKey) { 342 return mExpandedNotifications.contains(groupKey); 343 } 344 345 /** 346 * Gets the current {@link CarUxRestrictions}. 347 */ getCarUxRestrictions()348 public CarUxRestrictions getCarUxRestrictions() { 349 return mCarUxRestrictions; 350 } 351 352 /** 353 * Updates notifications and update views. 354 * 355 * @param setRecyclerViewListHeaderAndFooter sets the header and footer on the entire list of 356 * items within the recycler view. This is NOT the header/footer for the grouped notifications. 357 */ setNotifications(List<NotificationGroup> notifications, boolean setRecyclerViewListHeaderAndFooter)358 public void setNotifications(List<NotificationGroup> notifications, 359 boolean setRecyclerViewListHeaderAndFooter) { 360 361 notifications.removeIf(notificationGroup -> 362 mChildNotificationsBeingCleared.contains(notificationGroup.getSingleNotification()) 363 ); 364 365 List<NotificationGroup> notificationGroupList = new ArrayList<>(notifications); 366 367 if (setRecyclerViewListHeaderAndFooter) { 368 // add header as the first item of the list. 369 notificationGroupList.add(0, createNotificationHeader()); 370 // add footer as the last item of the list. 371 notificationGroupList.add(createNotificationFooter()); 372 mHasHeaderAndFooter = true; 373 } else { 374 mHasHeaderAndFooter = false; 375 } 376 377 DiffUtil.DiffResult diffResult = 378 DiffUtil.calculateDiff( 379 new CarNotificationDiff(mContext, mNotifications, notificationGroupList, mMaxItems), 380 /* detectMoves= */ false); 381 mNotifications = notificationGroupList; 382 updateUnderlyingDataChanged(getUnrestrictedItemCount(), /* newAnchorIndex= */ 0); 383 diffResult.dispatchUpdatesTo(this); 384 } 385 /** 386 * Sets child notifications of the group notification that is in the process of being cleared. 387 * This prevents these child notifications from appearing briefly while the clearing process is 388 * running. 389 * 390 * <p>NOTE: To reset mChildNotificationsBeingCleared, pass an empty Set instead of null.</p> 391 * 392 * @param notificationsBeingCleared 393 */ setChildNotificationsBeingCleared(@onNull Set notificationsBeingCleared)394 protected void setChildNotificationsBeingCleared(@NonNull Set notificationsBeingCleared) { 395 mChildNotificationsBeingCleared = notificationsBeingCleared; 396 } 397 398 /** 399 * Notification list has header and footer by default. Therefore the min number of items in the 400 * adapter will always be two. If there are any notifications present the size will be more than 401 * two. 402 */ hasNotifications()403 private boolean hasNotifications() { 404 return getItemCount() > 2; 405 } 406 createNotificationHeader()407 private NotificationGroup createNotificationHeader() { 408 NotificationGroup notificationGroupWithHeader = new NotificationGroup(); 409 notificationGroupWithHeader.setHeader(true); 410 notificationGroupWithHeader.setGroupKey("notification_header"); 411 return notificationGroupWithHeader; 412 } 413 createNotificationFooter()414 private NotificationGroup createNotificationFooter() { 415 NotificationGroup notificationGroupWithFooter = new NotificationGroup(); 416 notificationGroupWithFooter.setFooter(true); 417 notificationGroupWithFooter.setGroupKey("notification_footer"); 418 return notificationGroupWithFooter; 419 } 420 421 /** Implementation of {@link PreprocessingManager.CallStateListener} **/ 422 @Override onCallStateChanged(boolean isInCall)423 public void onCallStateChanged(boolean isInCall) { 424 if (isInCall != mIsInCall) { 425 mIsInCall = isInCall; 426 notifyDataSetChanged(); 427 } 428 } 429 430 /** 431 * Sets the current {@link CarUxRestrictions}. 432 */ setCarUxRestrictions(CarUxRestrictions carUxRestrictions)433 public void setCarUxRestrictions(CarUxRestrictions carUxRestrictions) { 434 Log.d(TAG, "setCarUxRestrictions"); 435 mCarUxRestrictions = carUxRestrictions; 436 notifyDataSetChanged(); 437 } 438 439 /** 440 * Helper method that determines whether a notification is a messaging notification and 441 * should have restricted content (no message preview). 442 */ shouldRestrictMessagePreview()443 private boolean shouldRestrictMessagePreview() { 444 return mCarUxRestrictions != null && (mCarUxRestrictions.getActiveRestrictions() 445 & CarUxRestrictions.UX_RESTRICTIONS_NO_TEXT_MESSAGE) != 0; 446 } 447 448 /** 449 * Get root recycler view's view pool so that the child recycler view can share the same 450 * view pool with the parent. 451 */ getViewPool()452 public RecyclerView.RecycledViewPool getViewPool() { 453 if (mIsGroupNotificationAdapter) { 454 // currently only support one level of expansion. 455 throw new IllegalStateException("CarNotificationViewAdapter is a child adapter; " 456 + "its view pool should not be reused."); 457 } 458 return mViewPool; 459 } 460 461 /** 462 * Sets the NotificationClickHandlerFactory that allows for a hook to run a block off code 463 * when the notification is clicked. This is useful to dismiss a screen after 464 * a notification list clicked. 465 */ setClickHandlerFactory(NotificationClickHandlerFactory clickHandlerFactory)466 public void setClickHandlerFactory(NotificationClickHandlerFactory clickHandlerFactory) { 467 mClickHandlerFactory = clickHandlerFactory; 468 } 469 470 /** 471 * Sets NotificationDataManager that handles additional states for notifications such as "seen", 472 * and muting a messaging type notification. 473 * 474 * @param notificationDataManager An instance of NotificationDataManager. 475 */ setNotificationDataManager(NotificationDataManager notificationDataManager)476 public void setNotificationDataManager(NotificationDataManager notificationDataManager) { 477 mNotificationDataManager = notificationDataManager; 478 } 479 480 /** 481 * Set notification groups as seen. 482 * 483 * @param start Initial adapter position of the notification groups. 484 * @param end Final adapter position of the notification groups. 485 */ setNotificationsAsSeen(int start, int end)486 /* package */ void setNotificationsAsSeen(int start, int end) { 487 start = Math.max(start, 0); 488 end = Math.min(end, mNotifications.size() - 1); 489 490 if (mNotificationDataManager != null) { 491 List<AlertEntry> notifications = new ArrayList(); 492 for (int i = start; i <= end; i++) { 493 notifications.addAll(mNotifications.get(i).getChildNotifications()); 494 } 495 mNotificationDataManager.setNotificationsAsSeen(notifications); 496 } 497 } 498 499 @Override getConfigurationId()500 public int getConfigurationId() { 501 return R.id.notification_list_uxr_config; 502 } 503 } 504