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