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