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