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