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