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