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