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