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