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 android.app.Notification;
19 import android.content.Context;
20 import android.content.res.TypedArray;
21 import android.graphics.PorterDuff;
22 import android.graphics.PorterDuffColorFilter;
23 import android.graphics.drawable.Drawable;
24 import android.graphics.drawable.Icon;
25 import android.os.Build;
26 import android.os.Handler;
27 import android.os.Looper;
28 import android.util.AttributeSet;
29 import android.util.Log;
30 import android.view.View;
31 import android.widget.LinearLayout;
32 
33 import androidx.annotation.ColorInt;
34 import androidx.annotation.NonNull;
35 import androidx.annotation.Nullable;
36 import androidx.annotation.VisibleForTesting;
37 
38 import com.android.car.assist.client.CarAssistUtils;
39 import com.android.car.notification.AlertEntry;
40 import com.android.car.notification.NotificationClickHandlerFactory;
41 import com.android.car.notification.NotificationDataManager;
42 import com.android.car.notification.PreprocessingManager;
43 import com.android.car.notification.R;
44 
45 import java.util.ArrayList;
46 import java.util.List;
47 
48 /**
49  * Notification actions view that contains the buttons that fire actions.
50  */
51 public class CarNotificationActionsView extends LinearLayout implements
52         PreprocessingManager.CallStateListener {
53     private static final boolean DEBUG = Build.IS_DEBUGGABLE;
54     private static final String TAG = "CarNotificationActionsView";
55 
56     // Maximum 3 actions
57     // https://developer.android.com/reference/android/app/Notification.Builder.html#addAction
58     @VisibleForTesting
59     static final int MAX_NUM_ACTIONS = 3;
60     @VisibleForTesting
61     static final int FIRST_MESSAGE_ACTION_BUTTON_INDEX = 0;
62     @VisibleForTesting
63     static final int SECOND_MESSAGE_ACTION_BUTTON_INDEX = 1;
64     @VisibleForTesting
65     static final int THIRD_MESSAGE_ACTION_BUTTON_INDEX = 2;
66 
67     private final List<CarNotificationActionButton> mActionButtons = new ArrayList<>();
68     private final Context mContext;
69     private final CarAssistUtils mCarAssistUtils;
70     private final Drawable mActionButtonBackground;
71     private final Drawable mCallButtonBackground;
72     private final Drawable mDeclineButtonBackground;
73     @ColorInt
74     private final int mCallButtonTextColor;
75     @ColorInt
76     private final int mDeclineButtonTextColor;
77     private final Drawable mUnmuteButtonBackground;
78     private final String mReplyButtonText;
79     private final String mPlayButtonText;
80     private final String mMuteText;
81     private final String mUnmuteText;
82     @ColorInt
83     private final int mUnmuteTextColor;
84     private final boolean mEnableDirectReply;
85     private final boolean mEnablePlay;
86 
87     @VisibleForTesting
88     final Drawable mPlayButtonDrawable;
89     @VisibleForTesting
90     final Drawable mReplyButtonDrawable;
91     @VisibleForTesting
92     final Drawable mMuteButtonDrawable;
93     @VisibleForTesting
94     final Drawable mUnmuteButtonDrawable;
95 
96 
97     private NotificationDataManager mNotificationDataManager;
98     private NotificationClickHandlerFactory mNotificationClickHandlerFactory;
99     private AlertEntry mAlertEntry;
100     private boolean mIsCategoryCall;
101     private boolean mIsInCall;
102 
CarNotificationActionsView(Context context)103     public CarNotificationActionsView(Context context) {
104         this(context, /* attrs= */ null);
105     }
106 
CarNotificationActionsView(Context context, AttributeSet attrs)107     public CarNotificationActionsView(Context context, AttributeSet attrs) {
108         this(context, attrs, /* defStyleAttr= */ 0);
109     }
110 
CarNotificationActionsView(Context context, AttributeSet attrs, int defStyleAttr)111     public CarNotificationActionsView(Context context, AttributeSet attrs, int defStyleAttr) {
112         this(context, attrs, defStyleAttr, /* defStyleRes= */ 0);
113     }
114 
CarNotificationActionsView( Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes)115     public CarNotificationActionsView(
116             Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
117         this(context, attrs, defStyleAttr, defStyleRes, new CarAssistUtils(context));
118     }
119 
120     @VisibleForTesting
CarNotificationActionsView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes, @NonNull CarAssistUtils carAssistUtils)121     CarNotificationActionsView(Context context, AttributeSet attrs, int defStyleAttr,
122             int defStyleRes, @NonNull CarAssistUtils carAssistUtils) {
123         super(context, attrs, defStyleAttr, defStyleRes);
124 
125         mContext = context;
126         mCarAssistUtils = carAssistUtils;
127         mNotificationDataManager = NotificationDataManager.getInstance();
128         mActionButtonBackground = mContext.getDrawable(R.drawable.action_button_background);
129         mCallButtonBackground = mContext.getDrawable(R.drawable.call_action_button_background);
130         mCallButtonBackground.setColorFilter(
131                 new PorterDuffColorFilter(mContext.getColor(R.color.call_accept_button),
132                         PorterDuff.Mode.SRC_IN));
133         mDeclineButtonBackground = mContext.getDrawable(R.drawable.call_action_button_background);
134         mDeclineButtonBackground.setColorFilter(
135                 new PorterDuffColorFilter(mContext.getColor(R.color.call_decline_button),
136                         PorterDuff.Mode.SRC_IN));
137         mCallButtonTextColor = mContext.getColor(R.color.call_accept_button_text);
138         mDeclineButtonTextColor = mContext.getColor(R.color.call_decline_button_text);
139         mUnmuteButtonBackground = mContext.getDrawable(R.drawable.call_action_button_background);
140         mUnmuteButtonBackground.setColorFilter(
141                 new PorterDuffColorFilter(mContext.getColor(R.color.unmute_button),
142                         PorterDuff.Mode.SRC_IN));
143         mPlayButtonText = mContext.getString(R.string.assist_action_play_label);
144         mReplyButtonText = mContext.getString(R.string.assist_action_reply_label);
145         mMuteText = mContext.getString(R.string.action_mute_short);
146         mUnmuteText = mContext.getString(R.string.action_unmute_short);
147         mPlayButtonDrawable = mContext.getDrawable(R.drawable.ic_play_arrow);
148         mReplyButtonDrawable = mContext.getDrawable(R.drawable.ic_reply);
149         mMuteButtonDrawable = mContext.getDrawable(R.drawable.ic_mute);
150         mUnmuteButtonDrawable = mContext.getDrawable(R.drawable.ic_unmute);
151         mEnablePlay =
152                 mContext.getResources().getBoolean(R.bool.config_enableMessageNotificationPlay);
153         mEnableDirectReply = mContext.getResources()
154                 .getBoolean(R.bool.config_enableMessageNotificationDirectReply);
155         mUnmuteTextColor = mContext.getColor(R.color.dark_icon_tint);
156         init(attrs);
157     }
158 
159     @VisibleForTesting
setNotificationDataManager(NotificationDataManager notificationDataManager)160     void setNotificationDataManager(NotificationDataManager notificationDataManager) {
161         mNotificationDataManager = notificationDataManager;
162     }
163 
init(@ullable AttributeSet attrs)164     private void init(@Nullable AttributeSet attrs) {
165         if (attrs != null) {
166             TypedArray attributes =
167                     mContext.obtainStyledAttributes(attrs, R.styleable.CarNotificationActionsView);
168             mIsCategoryCall =
169                     attributes.getBoolean(R.styleable.CarNotificationActionsView_categoryCall,
170                             /* defaultValue= */ false);
171             attributes.recycle();
172         }
173 
174         inflate(mContext, R.layout.car_notification_actions_view, /* root= */ this);
175     }
176 
177     /**
178      * Binds the notification action buttons.
179      *
180      * @param clickHandlerFactory factory class used to generate {@link OnClickListener}s.
181      * @param alertEntry          the notification that contains the actions.
182      */
bind(NotificationClickHandlerFactory clickHandlerFactory, AlertEntry alertEntry)183     public void bind(NotificationClickHandlerFactory clickHandlerFactory, AlertEntry alertEntry) {
184         Notification notification = alertEntry.getNotification();
185         Notification.Action[] actions = notification.actions;
186         if (actions == null || actions.length == 0) {
187             setVisibility(View.GONE);
188             return;
189         }
190 
191         PreprocessingManager.getInstance(mContext).addCallStateListener(this);
192 
193         mNotificationClickHandlerFactory = clickHandlerFactory;
194         mAlertEntry = alertEntry;
195 
196         setVisibility(View.VISIBLE);
197 
198         if (CarAssistUtils.isCarCompatibleMessagingNotification(
199                 alertEntry.getStatusBarNotification())) {
200             boolean canPlayMessage = mEnablePlay && mCarAssistUtils.hasActiveAssistant()
201                     || mCarAssistUtils.isFallbackAssistantEnabled();
202             boolean canReplyMessage = mEnableDirectReply && mCarAssistUtils.hasActiveAssistant()
203                     && clickHandlerFactory.getReplyAction(alertEntry.getNotification()) != null;
204             if (canPlayMessage) {
205                 createPlayButton(clickHandlerFactory, alertEntry);
206             }
207             if (canReplyMessage) {
208                 createReplyButton(clickHandlerFactory, alertEntry);
209             }
210             createMuteButton(clickHandlerFactory, alertEntry, canReplyMessage);
211             return;
212         }
213 
214         Context packageContext = alertEntry.getStatusBarNotification().getPackageContext(mContext);
215         int length = Math.min(actions.length, MAX_NUM_ACTIONS);
216         for (int i = 0; i < length; i++) {
217             Notification.Action action = actions[i];
218             CarNotificationActionButton button = mActionButtons.get(i);
219             button.setVisibility(View.VISIBLE);
220             // clear spannables and only use the text
221             button.setText(action.title.toString());
222 
223             if (action.actionIntent != null) {
224                 button.setOnClickListener(clickHandlerFactory.getActionClickHandler(alertEntry, i));
225             }
226 
227             Icon icon = action.getIcon();
228             if (icon != null) {
229                 icon.loadDrawableAsync(packageContext, button::setImageDrawable, getAsyncHandler());
230             }
231         }
232 
233         if (mIsCategoryCall) {
234             mActionButtons.get(0).setBackground(mCallButtonBackground);
235             mActionButtons.get(1).setBackground(mDeclineButtonBackground);
236             mActionButtons.get(0).setTextColor(mCallButtonTextColor);
237             mActionButtons.get(1).setTextColor(mDeclineButtonTextColor);
238         }
239     }
240 
241     /**
242      * Resets the notification actions empty for recycling.
243      */
reset()244     public void reset() {
245         resetButtons();
246         PreprocessingManager.getInstance(getContext()).removeCallStateListener(this);
247         mAlertEntry = null;
248         mNotificationClickHandlerFactory = null;
249     }
250 
resetButtons()251     private void resetButtons() {
252         for (CarNotificationActionButton button : mActionButtons) {
253             button.setVisibility(View.GONE);
254             button.setText(null);
255             button.setImageDrawable(null);
256             button.setOnClickListener(null);
257         }
258     }
259 
260     @Override
onFinishInflate()261     protected void onFinishInflate() {
262         super.onFinishInflate();
263         mActionButtons.add(findViewById(R.id.action_1));
264         mActionButtons.add(findViewById(R.id.action_2));
265         mActionButtons.add(findViewById(R.id.action_3));
266     }
267 
268     @VisibleForTesting
getActionButtons()269     List<CarNotificationActionButton> getActionButtons() {
270         return mActionButtons;
271     }
272 
273     @VisibleForTesting
setCategoryIsCall(boolean isCall)274     void setCategoryIsCall(boolean isCall) {
275         mIsCategoryCall = isCall;
276     }
277 
278     /**
279      * The Play button triggers the assistant to read the message aloud, optionally prompting the
280      * user to reply to the message afterwards.
281      */
createPlayButton(NotificationClickHandlerFactory clickHandlerFactory, AlertEntry alertEntry)282     private void createPlayButton(NotificationClickHandlerFactory clickHandlerFactory,
283             AlertEntry alertEntry) {
284         if (mIsInCall) return;
285 
286         CarNotificationActionButton button = mActionButtons.get(FIRST_MESSAGE_ACTION_BUTTON_INDEX);
287         button.setText(mPlayButtonText);
288         button.setImageDrawable(mPlayButtonDrawable);
289         button.setVisibility(View.VISIBLE);
290         button.setOnClickListener(
291                 clickHandlerFactory.getPlayClickHandler(alertEntry));
292     }
293 
294     /**
295      * The Reply button triggers the assistant to read the message aloud, optionally prompting the
296      * user to reply to the message afterwards.
297      */
createReplyButton(NotificationClickHandlerFactory clickHandlerFactory, AlertEntry alertEntry)298     private void createReplyButton(NotificationClickHandlerFactory clickHandlerFactory,
299             AlertEntry alertEntry) {
300         if (mIsInCall) return;
301         int index = SECOND_MESSAGE_ACTION_BUTTON_INDEX;
302 
303         CarNotificationActionButton button = mActionButtons.get(index);
304         button.setText(mReplyButtonText);
305         button.setImageDrawable(mReplyButtonDrawable);
306         button.setVisibility(View.VISIBLE);
307         button.setOnClickListener(
308                 clickHandlerFactory.getReplyClickHandler(alertEntry));
309     }
310 
311     /**
312      * The Mute button allows users to toggle whether or not incoming notification with the same
313      * statusBarNotification key will be shown with a HUN and trigger a notification sound.
314      */
createMuteButton(NotificationClickHandlerFactory clickHandlerFactory, AlertEntry alertEntry, boolean canReply)315     private void createMuteButton(NotificationClickHandlerFactory clickHandlerFactory,
316             AlertEntry alertEntry, boolean canReply) {
317         int index = THIRD_MESSAGE_ACTION_BUTTON_INDEX;
318         if (!canReply) index = SECOND_MESSAGE_ACTION_BUTTON_INDEX;
319         if (mIsInCall) index = FIRST_MESSAGE_ACTION_BUTTON_INDEX;
320 
321         CarNotificationActionButton button = mActionButtons.get(index);
322         setMuteStatus(button, mNotificationDataManager.isMessageNotificationMuted(alertEntry));
323         button.setVisibility(View.VISIBLE);
324         button.setOnClickListener(
325                 clickHandlerFactory.getMuteClickHandler(button, alertEntry, this::setMuteStatus));
326     }
327 
setMuteStatus(CarNotificationActionButton button, boolean isMuted)328     private void setMuteStatus(CarNotificationActionButton button, boolean isMuted) {
329         button.setText(isMuted ? mUnmuteText : mMuteText);
330         button.setTextColor(isMuted ? mUnmuteTextColor : button.getDefaultTextColor());
331         button.setImageDrawable(isMuted ? mUnmuteButtonDrawable : mMuteButtonDrawable);
332         button.setBackground(isMuted ? mUnmuteButtonBackground : mActionButtonBackground);
333     }
334 
335     /** Implementation of {@link PreprocessingManager.CallStateListener} **/
336     @Override
onCallStateChanged(boolean isInCall)337     public void onCallStateChanged(boolean isInCall) {
338         if (mIsInCall == isInCall) {
339             return;
340         }
341 
342         mIsInCall = isInCall;
343 
344         if (mNotificationClickHandlerFactory == null || mAlertEntry == null) {
345             return;
346         }
347 
348         if (DEBUG) {
349             if (isInCall) {
350                 Log.d(TAG, "Call state activated: " + mAlertEntry);
351             } else {
352                 Log.d(TAG, "Call state deactivated: " + mAlertEntry);
353             }
354         }
355 
356         int focusedButtonIndex = getFocusedButtonIndex();
357         resetButtons();
358         bind(mNotificationClickHandlerFactory, mAlertEntry);
359 
360         // If not in touch mode and action button had focus, then have original or preceding button
361         // request focus.
362         if (!isInTouchMode() && focusedButtonIndex != -1) {
363             for (int i = focusedButtonIndex; i != -1; i--) {
364                 CarNotificationActionButton button = getActionButtons().get(i);
365                 if (button.getVisibility() == View.VISIBLE) {
366                     button.requestFocus();
367                     return;
368                 }
369             }
370         }
371     }
372 
getFocusedButtonIndex()373     private int getFocusedButtonIndex() {
374         for (int i = FIRST_MESSAGE_ACTION_BUTTON_INDEX; i <= THIRD_MESSAGE_ACTION_BUTTON_INDEX;
375                 i++) {
376             boolean hasFocus = getActionButtons().get(i).hasFocus();
377             if (hasFocus) {
378                 return i;
379             }
380         }
381         return -1;
382     }
383 
384     /** Will be overwritten by test to return a mock Handler **/
385     @VisibleForTesting
getAsyncHandler()386     Handler getAsyncHandler() {
387         return Handler.createAsync(Looper.myLooper());
388     }
389 }
390