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.template; 17 18 import static android.app.Notification.EXTRA_SUBSTITUTE_APP_NAME; 19 20 import android.annotation.Nullable; 21 import android.app.Notification; 22 import android.car.drivingstate.CarUxRestrictions; 23 import android.car.drivingstate.CarUxRestrictionsManager; 24 import android.content.Context; 25 import android.content.pm.PackageManager; 26 import android.graphics.Canvas; 27 import android.graphics.Paint; 28 import android.graphics.drawable.Drawable; 29 import android.service.notification.StatusBarNotification; 30 import android.text.TextUtils; 31 import android.util.Log; 32 import android.view.View; 33 import android.view.ViewGroup; 34 import android.widget.ImageView; 35 import android.widget.TextView; 36 37 import androidx.cardview.widget.CardView; 38 import androidx.recyclerview.widget.LinearLayoutManager; 39 import androidx.recyclerview.widget.RecyclerView; 40 import androidx.recyclerview.widget.SimpleItemAnimator; 41 42 import com.android.car.notification.AlertEntry; 43 import com.android.car.notification.CarNotificationItemTouchListener; 44 import com.android.car.notification.CarNotificationViewAdapter; 45 import com.android.car.notification.NotificationClickHandlerFactory; 46 import com.android.car.notification.NotificationGroup; 47 import com.android.car.notification.R; 48 import com.android.internal.annotations.VisibleForTesting; 49 50 import java.util.ArrayList; 51 import java.util.Collections; 52 import java.util.List; 53 54 /** 55 * ViewHolder that binds a list of notifications as a grouped notification. 56 */ 57 public class GroupNotificationViewHolder extends CarNotificationBaseViewHolder 58 implements CarUxRestrictionsManager.OnUxRestrictionsChangedListener { 59 private static final String TAG = "GroupNotificationViewHolder"; 60 61 private final CardView mCardView; 62 private final View mHeaderDividerView; 63 private final View mExpandedGroupHeader; 64 private final TextView mExpandedGroupHeaderTextView; 65 private final ImageView mToggleIcon; 66 private final TextView mExpansionFooterView; 67 private final View mExpansionFooterGroup; 68 private final RecyclerView mNotificationListView; 69 private final Drawable mExpandDrawable; 70 private final Drawable mCollapseDrawable; 71 private final Paint mPaint; 72 private final int mDividerHeight; 73 private final CarNotificationHeaderView mGroupHeaderView; 74 private final View mTouchInterceptorView; 75 private final boolean mUseLauncherIcon; 76 private final int mExpandedGroupNotificationIncrementSize; 77 private final String mShowLessText; 78 79 private CarNotificationViewAdapter mAdapter; 80 private CarNotificationViewAdapter mParentAdapter; 81 private AlertEntry mSummaryNotification; 82 private NotificationGroup mNotificationGroup; 83 private String mHeaderName; 84 private int mNumberOfShownNotifications; 85 private List<NotificationGroup> mNotificationGroupsShown; 86 private FocusRequestStates mCurrentFocusRequestState; 87 GroupNotificationViewHolder( View view, NotificationClickHandlerFactory clickHandlerFactory)88 public GroupNotificationViewHolder( 89 View view, NotificationClickHandlerFactory clickHandlerFactory) { 90 super(view, clickHandlerFactory); 91 92 mCurrentFocusRequestState = FocusRequestStates.NONE; 93 mCardView = itemView.findViewById(R.id.card_view); 94 mCardView.addOnAttachStateChangeListener(new View.OnAttachStateChangeListener() { 95 @Override 96 public void onViewAttachedToWindow(View v) { 97 if (v.isInTouchMode()) { 98 return; 99 } 100 if (mCurrentFocusRequestState != FocusRequestStates.CARD_VIEW) { 101 return; 102 } 103 v.requestFocus(); 104 } 105 106 @Override 107 public void onViewDetachedFromWindow(View v) { 108 // no-op 109 } 110 }); 111 mGroupHeaderView = view.findViewById(R.id.group_header); 112 mExpandedGroupHeader = view.findViewById(R.id.expanded_group_header); 113 mExpandedGroupHeader.addOnAttachStateChangeListener(new View.OnAttachStateChangeListener() { 114 @Override 115 public void onViewAttachedToWindow(View v) { 116 if (v.isInTouchMode()) { 117 return; 118 } 119 if (mCurrentFocusRequestState != FocusRequestStates.EXPANDED_GROUP_HEADER) { 120 return; 121 } 122 v.requestFocus(); 123 } 124 125 @Override 126 public void onViewDetachedFromWindow(View v) { 127 // no-op 128 } 129 }); 130 mHeaderDividerView = view.findViewById(R.id.header_divider); 131 mToggleIcon = view.findViewById(R.id.group_toggle_icon); 132 mExpansionFooterView = view.findViewById(R.id.expansion_footer); 133 mExpansionFooterGroup = view.findViewById(R.id.expansion_footer_holder); 134 mExpandedGroupHeaderTextView = view.findViewById(R.id.expanded_group_header_text); 135 mNotificationListView = view.findViewById(R.id.notification_list); 136 mTouchInterceptorView = view.findViewById(R.id.touch_interceptor_view); 137 138 mExpandDrawable = getContext().getDrawable(R.drawable.expand_more); 139 mCollapseDrawable = getContext().getDrawable(R.drawable.expand_less); 140 141 mPaint = new Paint(); 142 mPaint.setColor(getContext().getColor(R.color.notification_list_divider_color)); 143 mDividerHeight = getContext().getResources().getDimensionPixelSize( 144 R.dimen.notification_list_divider_height); 145 mUseLauncherIcon = getContext().getResources().getBoolean(R.bool.config_useLauncherIcon); 146 mExpandedGroupNotificationIncrementSize = getContext().getResources() 147 .getInteger(R.integer.config_expandedGroupNotificationIncrementSize); 148 mShowLessText = getContext().getString(R.string.collapse_group); 149 150 mNotificationListView.setLayoutManager(new LinearLayoutManager(getContext()) { 151 @Override 152 public boolean supportsPredictiveItemAnimations() { 153 return false; 154 } 155 }); 156 mNotificationListView.addItemDecoration(new GroupedNotificationItemDecoration()); 157 ((SimpleItemAnimator) mNotificationListView.getItemAnimator()) 158 .setSupportsChangeAnimations(false); 159 mNotificationListView.setNestedScrollingEnabled(false); 160 mAdapter = new CarNotificationViewAdapter(getContext(), /* isGroupNotificationAdapter= */ 161 true, /* notificationItemController= */ null); 162 mAdapter.setClickHandlerFactory(clickHandlerFactory); 163 mNotificationListView.addOnItemTouchListener( 164 new CarNotificationItemTouchListener(view.getContext(), mAdapter)); 165 mNotificationListView.setAdapter(mAdapter); 166 } 167 168 /** 169 * Because this view holder does not call {@link CarNotificationBaseViewHolder#bind}, 170 * we need to override this method. 171 */ 172 @Override getAlertEntry()173 public AlertEntry getAlertEntry() { 174 return mSummaryNotification; 175 } 176 177 /** 178 * Returns the notification group for this viewholder. 179 * 180 * @return NotificationGroup {@link NotificationGroup}. 181 */ getNotificationGroup()182 public NotificationGroup getNotificationGroup() { 183 return mNotificationGroup; 184 } 185 186 /** 187 * Group notification view holder is special in that it requires extra data to bind, 188 * therefore the standard bind() method is not used. We are calling super.reset() 189 * directly and binding the onclick listener manually because the card's on click behavior is 190 * different when collapsed/expanded. 191 */ bind(NotificationGroup group, CarNotificationViewAdapter parentAdapter, boolean isExpanded)192 public void bind(NotificationGroup group, CarNotificationViewAdapter parentAdapter, 193 boolean isExpanded) { 194 reset(); 195 196 mNotificationGroup = group; 197 mParentAdapter = parentAdapter; 198 mSummaryNotification = mNotificationGroup.getGroupSummaryNotification(); 199 mHeaderName = loadHeaderAppName(mSummaryNotification.getStatusBarNotification()); 200 mExpandedGroupHeaderTextView.setText(mHeaderName); 201 202 // Bind the notification's data to the headerView. 203 mGroupHeaderView.bind(mSummaryNotification, /* isInGroup= */ false); 204 // Set the header's UI attributes (i.e. smallIconColor, etc.) based on the BaseViewHolder. 205 bindHeader(mGroupHeaderView, /* isInGroup= */ false); 206 207 // use the same view pool with all the grouped notifications 208 // to increase the number of the shared views and reduce memory cost 209 // the view pool is created and stored in the root adapter 210 mNotificationListView.setRecycledViewPool(mParentAdapter.getViewPool()); 211 212 // notification cards 213 if (isExpanded) { 214 expandGroup(); 215 addNotifications(); 216 if (mUseLauncherIcon) { 217 if (!itemView.isInTouchMode()) { 218 mCurrentFocusRequestState = FocusRequestStates.EXPANDED_GROUP_HEADER; 219 } else { 220 mCurrentFocusRequestState = FocusRequestStates.NONE; 221 } 222 } 223 } else { 224 collapseGroup(); 225 if (mUseLauncherIcon) { 226 if (!itemView.isInTouchMode()) { 227 mCurrentFocusRequestState = FocusRequestStates.CARD_VIEW; 228 } else { 229 mCurrentFocusRequestState = FocusRequestStates.NONE; 230 } 231 } 232 } 233 } 234 235 /** 236 * Expands the {@link GroupNotificationViewHolder}. 237 */ expandGroup()238 private void expandGroup() { 239 mNumberOfShownNotifications = 0; 240 mHeaderDividerView.setVisibility(View.VISIBLE); 241 mNotificationGroupsShown = new ArrayList<>(); 242 if (mUseLauncherIcon) { 243 mExpandedGroupHeader.setVisibility(View.VISIBLE); 244 } else { 245 mExpandedGroupHeader.setVisibility(View.GONE); 246 } 247 } 248 249 /** 250 * Adds notifications to {@link GroupNotificationViewHolder}. 251 */ addNotifications()252 private void addNotifications() { 253 mNumberOfShownNotifications = 254 addNextPageOfNotificationsToList(mNotificationGroupsShown); 255 mAdapter.setNotifications( 256 mNotificationGroupsShown, /* setRecyclerViewListHeadersAndFooters= */ false); 257 updateExpansionIcon(/* isExpanded= */ true); 258 updateOnClickListener(/* isExpanded= */ true); 259 } 260 261 /** 262 * Collapses the {@link GroupNotificationViewHolder}. 263 */ collapseGroup()264 public void collapseGroup() { 265 mExpandedGroupHeader.setVisibility(View.GONE); 266 // hide header divider 267 mHeaderDividerView.setVisibility(View.GONE); 268 269 NotificationGroup newGroup = new NotificationGroup(); 270 newGroup.setSeen(mNotificationGroup.isSeen()); 271 272 if (mUseLauncherIcon) { 273 // Only show first notification since notification header is not being used. 274 newGroup.addNotification(mNotificationGroup.getChildNotifications().get(0)); 275 mNumberOfShownNotifications = 1; 276 } else { 277 // Only show group summary notification 278 newGroup.addNotification(mNotificationGroup.getGroupSummaryNotification()); 279 // If the group summary notification is automatically generated, 280 // it does not contain a summary of the titles of the child notifications. 281 // Therefore, we generate a list of the child notification titles from 282 // the parent notification group, and pass them on. 283 newGroup.setChildTitles(mNotificationGroup.generateChildTitles()); 284 mNumberOfShownNotifications = 0; 285 } 286 287 mNotificationGroupsShown = new ArrayList(Collections.singleton(newGroup)); 288 mAdapter.setNotifications( 289 mNotificationGroupsShown, /* setRecyclerViewListHeadersAndFooters= */ false); 290 291 updateExpansionIcon(/* isExpanded= */ false); 292 updateOnClickListener(/* isExpanded= */ false); 293 } 294 updateExpansionIcon(boolean isExpanded)295 private void updateExpansionIcon(boolean isExpanded) { 296 // expansion button in the group header 297 if (mNotificationGroup.getChildCount() == 0) { 298 mToggleIcon.setVisibility(View.GONE); 299 return; 300 } 301 mExpansionFooterGroup.setVisibility(View.VISIBLE); 302 if (mUseLauncherIcon) { 303 mToggleIcon.setVisibility(View.GONE); 304 } else { 305 mToggleIcon.setImageDrawable(isExpanded ? mCollapseDrawable : mExpandDrawable); 306 mToggleIcon.setVisibility(View.VISIBLE); 307 } 308 309 // Don't allow most controls to be focused when collapsed. 310 mNotificationListView.setDescendantFocusability(isExpanded 311 ? ViewGroup.FOCUS_BEFORE_DESCENDANTS : ViewGroup.FOCUS_BLOCK_DESCENDANTS); 312 mNotificationListView.setFocusable(false); 313 mGroupHeaderView.setFocusable(isExpanded); 314 mExpansionFooterView.setFocusable(isExpanded); 315 316 int unshownCount = mNotificationGroup.getChildCount() - mNumberOfShownNotifications; 317 String footerText = getContext() 318 .getString(R.string.show_more_from_app, unshownCount, mHeaderName); 319 mExpansionFooterView.setText(footerText); 320 321 // expansion button in the group footer 322 if (isExpanded) { 323 hideDismissButton(); 324 return; 325 } 326 327 updateDismissButton(getAlertEntry(), /* isHeadsUp= */ false); 328 } 329 updateOnClickListener(boolean isExpanded)330 private void updateOnClickListener(boolean isExpanded) { 331 332 View.OnClickListener expansionClickListener = view -> { 333 boolean isExpanding = !isExpanded; 334 mParentAdapter.setExpanded(mNotificationGroup.getGroupKey(), 335 mNotificationGroup.isSeen(), 336 isExpanding); 337 if (isExpanding) { 338 expandGroup(); 339 addNotifications(); 340 } else { 341 collapseGroup(); 342 } 343 if (!itemView.isInTouchMode()) { 344 if (isExpanding) { 345 mCurrentFocusRequestState = FocusRequestStates.EXPANDED_GROUP_HEADER; 346 } else { 347 mCurrentFocusRequestState = FocusRequestStates.CARD_VIEW; 348 } 349 } else { 350 mCurrentFocusRequestState = FocusRequestStates.NONE; 351 } 352 }; 353 354 View.OnClickListener paginationClickListener = view -> { 355 if (!itemView.isInTouchMode() && mUseLauncherIcon) { 356 mCurrentFocusRequestState = FocusRequestStates.CHILD_NOTIFICATION; 357 mNotificationListView.smoothScrollToPosition(mNumberOfShownNotifications - 1); 358 mNotificationListView 359 .findViewHolderForAdapterPosition(mNumberOfShownNotifications - 1) 360 .itemView.requestFocus(); 361 } else { 362 mCurrentFocusRequestState = FocusRequestStates.NONE; 363 } 364 addNotifications(); 365 }; 366 367 if (isExpanded) { 368 mCardView.setOnClickListener(null); 369 mCardView.setClickable(false); 370 mCardView.setFocusable(false); 371 if (mNumberOfShownNotifications == mNotificationGroup.getChildCount()) { 372 mExpansionFooterView.setOnClickListener(expansionClickListener); 373 mExpansionFooterView.setText(mShowLessText); 374 } else { 375 mExpansionFooterView.setOnClickListener(paginationClickListener); 376 } 377 } else { 378 mCardView.setOnClickListener(expansionClickListener); 379 mExpansionFooterView.setOnClickListener(expansionClickListener); 380 } 381 mGroupHeaderView.setOnClickListener(expansionClickListener); 382 mExpandedGroupHeader.setOnClickListener(expansionClickListener); 383 mTouchInterceptorView.setOnClickListener(expansionClickListener); 384 mTouchInterceptorView.setVisibility(isExpanded ? View.GONE : View.VISIBLE); 385 } 386 387 // Returns new size of group list addNextPageOfNotificationsToList(List<NotificationGroup> groups)388 private int addNextPageOfNotificationsToList(List<NotificationGroup> groups) { 389 int pageEnd = mNumberOfShownNotifications + mExpandedGroupNotificationIncrementSize; 390 for (int i = mNumberOfShownNotifications; i < mNotificationGroup.getChildCount() 391 && i < pageEnd; i++) { 392 AlertEntry notification = mNotificationGroup.getChildNotifications().get(i); 393 NotificationGroup notificationGroup = new NotificationGroup(); 394 notificationGroup.addNotification(notification); 395 notificationGroup.setSeen(mNotificationGroup.isSeen()); 396 groups.add(notificationGroup); 397 } 398 return groups.size(); 399 } 400 401 @Override isDismissible()402 public boolean isDismissible() { 403 return mNotificationGroup == null || mNotificationGroup.isDismissible(); 404 } 405 406 @Override reset()407 void reset() { 408 super.reset(); 409 mCardView.setOnClickListener(null); 410 mGroupHeaderView.reset(); 411 } 412 413 @Override onUxRestrictionsChanged(CarUxRestrictions restrictionInfo)414 public void onUxRestrictionsChanged(CarUxRestrictions restrictionInfo) { 415 mAdapter.setCarUxRestrictions(mAdapter.getCarUxRestrictions()); 416 } 417 418 private class GroupedNotificationItemDecoration extends RecyclerView.ItemDecoration { 419 420 @Override onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state)421 public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) { 422 // not drawing the divider for the last item 423 for (int i = 0; i < parent.getChildCount() - 1; i++) { 424 drawDivider(c, parent.getChildAt(i)); 425 } 426 } 427 428 /** 429 * Draws a divider under {@code container}. 430 */ drawDivider(Canvas c, View container)431 private void drawDivider(Canvas c, View container) { 432 int left = container.getLeft(); 433 int right = container.getRight(); 434 int bottom = container.getBottom() + mDividerHeight; 435 int top = bottom - mDividerHeight; 436 437 c.drawRect(left, top, right, bottom, mPaint); 438 } 439 } 440 441 /** 442 * Fetches the application label given the notification. If the notification is a system 443 * generated message notification that is posting on behalf of another application, that 444 * application's name is used. 445 * 446 * The system permission {@link android.Manifest.permission#SUBSTITUTE_NOTIFICATION_APP_NAME} 447 * is required to post on behalf of another application. The notification extra should also 448 * contain a key {@link Notification#EXTRA_SUBSTITUTE_APP_NAME} with the value of 449 * the appropriate application name. 450 * 451 * @return application label. Returns {@code null} when application name is not found. 452 */ 453 @Nullable loadHeaderAppName(StatusBarNotification sbn)454 private String loadHeaderAppName(StatusBarNotification sbn) { 455 Context packageContext = sbn.getPackageContext(getContext()); 456 PackageManager pm = packageContext.getPackageManager(); 457 Notification notification = sbn.getNotification(); 458 CharSequence name = pm.getApplicationLabel(packageContext.getApplicationInfo()); 459 String subName = notification.extras.getString(EXTRA_SUBSTITUTE_APP_NAME); 460 if (subName != null) { 461 // Only system packages which lump together a bunch of unrelated stuff may substitute a 462 // different name to make the purpose of the notification more clear. 463 // The correct package label should always be accessible via SystemUI. 464 String pkg = sbn.getPackageName(); 465 if (PackageManager.PERMISSION_GRANTED == pm.checkPermission( 466 android.Manifest.permission.SUBSTITUTE_NOTIFICATION_APP_NAME, pkg)) { 467 name = subName; 468 } else { 469 Log.w(TAG, "warning: pkg " 470 + pkg + " attempting to substitute app name '" + subName 471 + "' without holding perm " 472 + android.Manifest.permission.SUBSTITUTE_NOTIFICATION_APP_NAME); 473 } 474 } 475 if (TextUtils.isEmpty(name)) { 476 return null; 477 } 478 return String.valueOf(name); 479 } 480 481 private enum FocusRequestStates { 482 CHILD_NOTIFICATION, 483 EXPANDED_GROUP_HEADER, 484 CARD_VIEW, 485 NONE, 486 } 487 488 @VisibleForTesting setAdapter(CarNotificationViewAdapter adapter)489 void setAdapter(CarNotificationViewAdapter adapter) { 490 mAdapter = adapter; 491 } 492 } 493