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