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 
17 package com.android.car.notification;
18 
19 import static android.app.ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED;
20 
21 import android.annotation.Nullable;
22 import android.app.ActivityManager;
23 import android.app.ActivityOptions;
24 import android.app.Notification;
25 import android.app.PendingIntent;
26 import android.app.RemoteInput;
27 import android.content.Context;
28 import android.content.Intent;
29 import android.os.Bundle;
30 import android.os.Handler;
31 import android.os.Looper;
32 import android.os.RemoteException;
33 import android.service.notification.NotificationStats;
34 import android.util.Log;
35 import android.view.View;
36 import android.widget.Toast;
37 
38 import androidx.annotation.VisibleForTesting;
39 import androidx.core.app.NotificationCompat;
40 
41 import com.android.car.assist.CarVoiceInteractionSession;
42 import com.android.car.assist.client.CarAssistUtils;
43 import com.android.car.notification.template.CarNotificationActionButton;
44 import com.android.internal.statusbar.IStatusBarService;
45 import com.android.internal.statusbar.NotificationVisibility;
46 
47 import java.util.ArrayList;
48 import java.util.List;
49 
50 /**
51  * Factory that builds a {@link View.OnClickListener} to handle the logic of what to do when a
52  * notification is clicked. It also handles the interaction with the StatusBarService.
53  */
54 public class NotificationClickHandlerFactory {
55 
56     /**
57      * Callback that will be issued after a notification is clicked.
58      */
59     public interface OnNotificationClickListener {
60 
61         /**
62          * A notification was clicked and handleNotificationClicked was invoked.
63          *
64          * @param launchResult For non-Assistant actions, returned from
65          *        {@link PendingIntent#sendAndReturnResult}; for Assistant actions,
66          *        returns {@link ActivityManager#START_SUCCESS} on success;
67          *        {@link ActivityManager#START_ABORTED} otherwise.
68          *
69          * @param alertEntry {@link AlertEntry} whose Notification was clicked.
70          */
onNotificationClicked(int launchResult, AlertEntry alertEntry)71         void onNotificationClicked(int launchResult, AlertEntry alertEntry);
72     }
73 
74     private static final String TAG = "NotificationClickHandlerFactory";
75 
76     private final IStatusBarService mBarService;
77     private final List<OnNotificationClickListener> mClickListeners = new ArrayList<>();
78     private CarAssistUtils mCarAssistUtils;
79     @Nullable
80     private NotificationDataManager mNotificationDataManager;
81     private Handler mMainHandler;
82     private OnNotificationClickListener mHunDismissCallback;
83 
NotificationClickHandlerFactory(IStatusBarService barService)84     public NotificationClickHandlerFactory(IStatusBarService barService) {
85         mBarService = barService;
86         mCarAssistUtils = null;
87         mMainHandler = new Handler(Looper.getMainLooper());
88         mNotificationDataManager = NotificationDataManager.getInstance();
89     }
90 
91     @VisibleForTesting
setCarAssistUtils(CarAssistUtils carAssistUtils)92     void setCarAssistUtils(CarAssistUtils carAssistUtils) {
93         mCarAssistUtils = carAssistUtils;
94     }
95 
96     /**
97      * Returns a {@link View.OnClickListener} that should be used for the given
98      * {@link AlertEntry}
99      *
100      * @param alertEntry that will be considered clicked when onClick is called.
101      */
getClickHandler(AlertEntry alertEntry)102     public View.OnClickListener getClickHandler(AlertEntry alertEntry) {
103         return v -> {
104             Notification notification = alertEntry.getNotification();
105             final PendingIntent intent = notification.contentIntent != null
106                     ? notification.contentIntent
107                     : notification.fullScreenIntent;
108             if (intent == null) {
109                 return;
110             }
111 
112             int result = sendPendingIntent(intent, /* context= */ null, /* resultIntent= */ null);
113             NotificationVisibility notificationVisibility = NotificationVisibility.obtain(
114                     alertEntry.getKey(),
115                     /* rank= */ -1, /* count= */ -1, /* visible= */ true);
116             try {
117                 mBarService.onNotificationClick(alertEntry.getKey(),
118                         notificationVisibility);
119                 if (shouldAutoCancel(alertEntry)) {
120                     clearNotification(alertEntry);
121                 }
122             } catch (RemoteException ex) {
123                 Log.e(TAG, "Remote exception in getClickHandler", ex);
124             }
125             handleNotificationClicked(result, alertEntry);
126         };
127 
128     }
129 
130     /**
131      * Returns a {@link View.OnClickListener} that should be used for the
132      * {@link android.app.Notification.Action} contained in the {@link AlertEntry}
133      *
134      * @param alertEntry that contains the clicked action.
135      * @param index the index of the action clicked.
136      */
getActionClickHandler(AlertEntry alertEntry, int index)137     public View.OnClickListener getActionClickHandler(AlertEntry alertEntry, int index) {
138         return v -> {
139             Notification notification = alertEntry.getNotification();
140             Notification.Action action = notification.actions[index];
141             NotificationVisibility notificationVisibility = NotificationVisibility.obtain(
142                     alertEntry.getKey(),
143                     /* rank= */ -1, /* count= */ -1, /* visible= */ true);
144             boolean canceledExceptionThrown = false;
145             int semanticAction = action.getSemanticAction();
146             if (CarAssistUtils.isCarCompatibleMessagingNotification(
147                     alertEntry.getStatusBarNotification())) {
148                 if (semanticAction == Notification.Action.SEMANTIC_ACTION_REPLY) {
149                     Context context = v.getContext().getApplicationContext();
150                     Intent resultIntent = addCannedReplyMessage(action, context);
151                     int result = sendPendingIntent(action.actionIntent, context, resultIntent);
152                     if (result == ActivityManager.START_SUCCESS) {
153                         showToast(context, R.string.toast_message_sent_success);
154                     } else if (result == ActivityManager.START_ABORTED) {
155                         canceledExceptionThrown = true;
156                     }
157                 }
158             } else {
159                 int result = sendPendingIntent(action.actionIntent, /* context= */ null,
160                         /* resultIntent= */ null);
161                 if (result == ActivityManager.START_ABORTED) {
162                     canceledExceptionThrown = true;
163                 }
164                 handleNotificationClicked(result, alertEntry);
165             }
166             if (!canceledExceptionThrown) {
167                 try {
168                     mBarService.onNotificationActionClick(
169                             alertEntry.getKey(),
170                             index,
171                             action,
172                             notificationVisibility,
173                             /* generatedByAssistant= */ false);
174                 } catch (RemoteException e) {
175                     Log.e(TAG, "Remote exception in getActionClickHandler", e);
176                 }
177             }
178         };
179     }
180 
181     /**
182      * Returns a {@link View.OnClickListener} that should be used for the
183      * {@param messageNotification}'s {@param playButton}. Once the message is read aloud, the
184      * pending intent should be returned to the messaging app, so it can mark it as read.
185      */
186     public View.OnClickListener getPlayClickHandler(AlertEntry messageNotification) {
187         return view -> {
188             if (!CarAssistUtils.isCarCompatibleMessagingNotification(
189                     messageNotification.getStatusBarNotification())) {
190                 return;
191             }
192             Context context = view.getContext().getApplicationContext();
193             if (mCarAssistUtils == null) {
194                 mCarAssistUtils = new CarAssistUtils(context);
195             }
196             CarAssistUtils.ActionRequestCallback requestCallback = resultState -> {
197                 if (CarAssistUtils.ActionRequestCallback.RESULT_FAILED.equals(resultState)) {
198                     showToast(context, R.string.assist_action_failed_toast);
199                     Log.e(TAG, "Assistant failed to read aloud the message");
200                 }
201                 // Don't trigger mCallback so the shade remains open.
202             };
203             mCarAssistUtils.requestAssistantVoiceAction(
204                     messageNotification.getStatusBarNotification(),
205                     CarVoiceInteractionSession.VOICE_ACTION_READ_NOTIFICATION,
206                     requestCallback);
207 
208             if (context.getResources().getBoolean(
209                     R.bool.config_dismissMessageHunWhenReplyOrPlayActionButtonPressed)) {
210                 mHunDismissCallback.onNotificationClicked(/* launchResult= */ 0,
211                         messageNotification);
212             }
213         };
214     }
215 
216     /**
217      * Returns a {@link View.OnClickListener} that should be used for the
218      * {@param messageNotification}'s {@param replyButton}.
219      */
220     public View.OnClickListener getReplyClickHandler(AlertEntry messageNotification) {
221         return view -> {
222             if (getReplyAction(messageNotification.getNotification()) == null) {
223                 return;
224             }
225             Context context = view.getContext().getApplicationContext();
226             if (mCarAssistUtils == null) {
227                 mCarAssistUtils = new CarAssistUtils(context);
228             }
229             CarAssistUtils.ActionRequestCallback requestCallback = resultState -> {
230                 if (CarAssistUtils.ActionRequestCallback.RESULT_FAILED.equals(resultState)) {
231                     showToast(context, R.string.assist_action_failed_toast);
232                     Log.e(TAG, "Assistant failed to read aloud the message");
233                 }
234                 // Don't trigger mCallback so the shade remains open.
235             };
236             mCarAssistUtils.requestAssistantVoiceAction(
237                     messageNotification.getStatusBarNotification(),
238                     CarVoiceInteractionSession.VOICE_ACTION_REPLY_NOTIFICATION,
239                     requestCallback);
240 
241             if (context.getResources().getBoolean(
242                     R.bool.config_dismissMessageHunWhenReplyOrPlayActionButtonPressed)) {
243                 mHunDismissCallback.onNotificationClicked(/* launchResult= */ 0,
244                         messageNotification);
245             }
246         };
247     }
248 
249     /**
250      * Returns a {@link View.OnClickListener} that should be used for the
251      * {@param messageNotification}'s {@param muteButton}.
252      */
253     public View.OnClickListener getMuteClickHandler(
254             CarNotificationActionButton muteButton, AlertEntry messageNotification,
255             MuteStatusSetter setter) {
256         return v -> {
257             NotificationCompat.Action action =
258                     CarAssistUtils.getMuteAction(messageNotification.getNotification());
259             Log.d(TAG, action == null ? "Mute action is null, using built-in logic." :
260                     "Mute action is not null, deferring muting behavior to app");
261 
262             if (action != null && action.getActionIntent() != null) {
263                 try {
264                     action.getActionIntent().send();
265                     // clear all notifications when mute button is clicked.
266                     // once a mute pending intent is provided,
267                     // the mute functionality is fully delegated to the app who will handle
268                     // the mute state and ability to toggle on and off a notification.
269                     // This is necessary to ensure that mute state has one single source of truth.
270                     clearNotification(messageNotification);
271                 } catch (PendingIntent.CanceledException e) {
272                     Log.d(TAG, "Could not send pending intent to mute notification "
273                             + e.getLocalizedMessage());
274                 }
275             } else if (mNotificationDataManager != null) {
276                 mNotificationDataManager.toggleMute(messageNotification);
277                 setter.setMuteStatus(muteButton,
278                         mNotificationDataManager.isMessageNotificationMuted(messageNotification));
279                 // Don't trigger mCallback so the shade remains open.
280             } else {
281                 Log.d(TAG, "Could not set mute click handler as NotificationDataManager is null");
282             }
283         };
284     }
285 
286     /**
287      * Sets mute status for a {@link CarNotificationActionButton}.
288      */
289     public interface MuteStatusSetter {
290         /**
291          * Sets mute status for a {@link CarNotificationActionButton}.
292          *
293          * @param button Mute button
294          * @param isMuted {@code true} if button should represent muted state
295          */
296         void setMuteStatus(CarNotificationActionButton button, boolean isMuted);
297     }
298 
299     /**
300      * Returns a {@link View.OnClickListener} that should be used for the {@code alertEntry}'s
301      * dismiss button.
302      */
303     public View.OnClickListener getDismissHandler(AlertEntry alertEntry) {
304         return v -> clearNotification(alertEntry);
305     }
306 
307     /**
308      * Set a new {@link OnNotificationClickListener} to be used to dismiss HUNs.
309      */
310     public void setHunDismissCallback(OnNotificationClickListener hunDismissCallback) {
311         mHunDismissCallback = hunDismissCallback;
312     }
313 
314     /**
315      * Registers a new {@link OnNotificationClickListener} to the list of click event listeners.
316      */
317     public void registerClickListener(OnNotificationClickListener clickListener) {
318         if (clickListener != null && !mClickListeners.contains(clickListener)) {
319             mClickListeners.add(clickListener);
320         }
321     }
322 
323     /**
324      * Unregisters a {@link OnNotificationClickListener} from the list of click event listeners.
325      */
326     public void unregisterClickListener(OnNotificationClickListener clickListener) {
327         mClickListeners.remove(clickListener);
328     }
329 
330     /**
331      * Clears all notifications.
332      */
333     public void clearAllNotifications(Context context) {
334         try {
335             mBarService.onClearAllNotifications(NotificationUtils.getCurrentUser(context));
336         } catch (RemoteException e) {
337             Log.e(TAG, "clearAllNotifications: ", e);
338         }
339     }
340 
341     /**
342      * Clears the notifications provided.
343      */
344     public void clearNotifications(List<NotificationGroup> notificationsToClear) {
345         notificationsToClear.forEach(notificationGroup -> {
346             if (notificationGroup.isGroup()) {
347                 AlertEntry summaryNotification = notificationGroup.getGroupSummaryNotification();
348                 clearNotification(summaryNotification);
349             }
350             notificationGroup.getChildNotifications()
351                     .forEach(alertEntry -> clearNotification(alertEntry));
352         });
353     }
354 
355     /**
356      * Collapses the notification shade panel.
357      */
358     public void collapsePanel() {
359         try {
360             mBarService.collapsePanels();
361         } catch (RemoteException e) {
362             Log.e(TAG, "collapsePanel: ", e);
363         }
364     }
365 
366     /**
367      * Invokes all onNotificationClicked handlers registered in {@link OnNotificationClickListener}s
368      * array.
369      */
370     private void handleNotificationClicked(int launchResult, AlertEntry alertEntry) {
371         mClickListeners.forEach(
372                 listener -> listener.onNotificationClicked(launchResult, alertEntry));
373     }
374 
375     private void clearNotification(AlertEntry alertEntry) {
376         try {
377             // rank and count is used for logging and is not need at this time thus -1
378             NotificationVisibility notificationVisibility = NotificationVisibility.obtain(
379                     alertEntry.getKey(),
380                     /* rank= */ -1,
381                     /* count= */ -1,
382                     /* visible= */ true);
383 
384             mBarService.onNotificationClear(
385                     alertEntry.getStatusBarNotification().getPackageName(),
386                     alertEntry.getStatusBarNotification().getUser().getIdentifier(),
387                     alertEntry.getStatusBarNotification().getKey(),
388                     NotificationStats.DISMISSAL_SHADE,
389                     NotificationStats.DISMISS_SENTIMENT_NEUTRAL,
390                     notificationVisibility);
391         } catch (RemoteException e) {
392             Log.e(TAG, "clearNotifications: ", e);
393         }
394     }
395 
396     private int sendPendingIntent(PendingIntent pendingIntent, Context context,
397             Intent resultIntent) {
398         // Needed to start activities on clicking the Notification
399         ActivityOptions options = ActivityOptions.makeBasic()
400                 .setPendingIntentBackgroundActivityStartMode(
401                         MODE_BACKGROUND_ACTIVITY_START_ALLOWED);
402         try {
403             return pendingIntent.sendAndReturnResult(/* context= */ context, /* code= */ 0,
404                     /* intent= */ resultIntent, /* onFinished= */null,
405                     /* handler= */ null, /* requiredPermissions= */ null, options.toBundle());
406         } catch (PendingIntent.CanceledException e) {
407             // Do not take down the app over this
408             Log.w(TAG, "Sending contentIntent failed: " + e);
409             return ActivityManager.START_ABORTED;
410         }
411     }
412 
413     /** Adds the canned reply sms message to the {@link Notification.Action}'s RemoteInput. **/
414     @Nullable
415     private Intent addCannedReplyMessage(Notification.Action action, Context context) {
416         RemoteInput remoteInput = action.getRemoteInputs()[0];
417         if (remoteInput == null) {
418             Log.w("TAG", "Cannot add canned reply message to action with no RemoteInput.");
419             return null;
420         }
421         Bundle messageDataBundle = new Bundle();
422         messageDataBundle.putCharSequence(remoteInput.getResultKey(),
423                 context.getString(R.string.canned_reply_message));
424         Intent resultIntent = new Intent();
425         RemoteInput.addResultsToIntent(
426                 new RemoteInput[]{remoteInput}, resultIntent, messageDataBundle);
427         return resultIntent;
428     }
429 
430     private void showToast(Context context, int resourceId) {
431         mMainHandler.post(
432                 Toast.makeText(context, context.getString(resourceId), Toast.LENGTH_LONG)::show);
433     }
434 
435     private boolean shouldAutoCancel(AlertEntry alertEntry) {
436         int flags = alertEntry.getNotification().flags;
437         if ((flags & Notification.FLAG_AUTO_CANCEL) != Notification.FLAG_AUTO_CANCEL) {
438             return false;
439         }
440         if ((flags & Notification.FLAG_FOREGROUND_SERVICE) != 0) {
441             return false;
442         }
443         return true;
444     }
445 
446     /**
447      * Retrieves the {@link NotificationCompat.Action} containing the
448      * {@link NotificationCompat.Action#SEMANTIC_ACTION_REPLY} semantic action.
449      */
450     @Nullable
451     public NotificationCompat.Action getReplyAction(Notification notification) {
452         for (NotificationCompat.Action action : CarAssistUtils.getAllActions(notification)) {
453             if (action.getSemanticAction()
454                     == NotificationCompat.Action.SEMANTIC_ACTION_REPLY) {
455                 return action;
456             }
457         }
458         return null;
459     }
460 }
461