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