• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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