1 /* 2 * Copyright (C) 2019 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 package com.android.car.assist.client; 17 18 import static android.app.Notification.Action.SEMANTIC_ACTION_MARK_AS_READ; 19 import static android.app.Notification.Action.SEMANTIC_ACTION_REPLY; 20 import static android.service.voice.VoiceInteractionSession.SHOW_SOURCE_NOTIFICATION; 21 22 import static com.android.car.assist.CarVoiceInteractionSession.EXCEPTION_NOTIFICATION_LISTENER_PERMISSIONS_MISSING; 23 24 import android.annotation.Nullable; 25 import android.app.ActivityManager; 26 import android.app.Notification; 27 import android.app.RemoteInput; 28 import android.content.Context; 29 import android.os.Bundle; 30 import android.provider.Settings; 31 import android.service.notification.StatusBarNotification; 32 import android.util.Log; 33 34 import androidx.annotation.StringDef; 35 import androidx.core.app.NotificationCompat; 36 import androidx.core.app.NotificationCompat.Action; 37 38 import com.android.car.assist.CarVoiceInteractionSession; 39 import com.android.internal.app.AssistUtils; 40 import com.android.internal.app.IVoiceActionCheckCallback; 41 42 import java.util.ArrayList; 43 import java.util.Arrays; 44 import java.util.Collections; 45 import java.util.HashSet; 46 import java.util.List; 47 import java.util.Objects; 48 import java.util.Set; 49 import java.util.stream.Collectors; 50 51 /** 52 * Util class providing helper methods to interact with the current active voice service, 53 * while ensuring that the active voice service has the required permissions. 54 */ 55 public class CarAssistUtils { 56 public static final String TAG = "CarAssistUtils"; 57 private static final List<Integer> REQUIRED_SEMANTIC_ACTIONS = Collections.unmodifiableList( 58 Arrays.asList( 59 SEMANTIC_ACTION_MARK_AS_READ 60 ) 61 ); 62 63 private static final List<Integer> SUPPORTED_SEMANTIC_ACTIONS = Collections.unmodifiableList( 64 Arrays.asList( 65 SEMANTIC_ACTION_MARK_AS_READ, 66 SEMANTIC_ACTION_REPLY 67 ) 68 ); 69 70 private final Context mContext; 71 private final AssistUtils mAssistUtils; 72 @Nullable 73 private final FallbackAssistant mFallbackAssistant; 74 private final String mErrorMessage; 75 private final boolean mIsFallbackAssistantEnabled; 76 77 /** Interface used to receive callbacks from voice action requests. */ 78 public interface ActionRequestCallback { 79 /** 80 * The action was successfully completed either by the active or fallback assistant. 81 **/ 82 String RESULT_SUCCESS = "SUCCESS"; 83 84 /** 85 * The action was not successfully completed, but the active assistant has been prompted to 86 * alert the user of this error and handle it. The caller of this callback is recommended 87 * to NOT alert the user of this error again. 88 */ 89 String RESULT_FAILED_WITH_ERROR_HANDLED = "FAILED_WITH_ERROR_HANDLED"; 90 91 /** 92 * The action has not been successfully completed, and the error has not been handled. 93 **/ 94 String RESULT_FAILED = "FAILED"; 95 96 /** 97 * The list of result states. 98 */ 99 @StringDef({RESULT_FAILED, RESULT_FAILED_WITH_ERROR_HANDLED, RESULT_SUCCESS}) 100 @interface ResultState { 101 } 102 103 /** Callback containing the result of completing the voice action request. */ onResult(@esultState String state)104 void onResult(@ResultState String state); 105 } 106 CarAssistUtils(Context context)107 public CarAssistUtils(Context context) { 108 mContext = context; 109 mAssistUtils = new AssistUtils(context); 110 mErrorMessage = context.getString(R.string.assist_action_failed_toast); 111 112 mIsFallbackAssistantEnabled = 113 context.getResources().getBoolean(R.bool.config_enableFallbackAssistant); 114 mFallbackAssistant = mIsFallbackAssistantEnabled ? new FallbackAssistant(context) : null; 115 } 116 117 /** 118 * @return {@code true} if there is an active assistant. 119 */ hasActiveAssistant()120 public boolean hasActiveAssistant() { 121 return mAssistUtils.getActiveServiceComponentName() != null; 122 } 123 124 /** 125 * Returns {@code true} if the fallback assistant is enabled. 126 */ isFallbackAssistantEnabled()127 public boolean isFallbackAssistantEnabled() { 128 return mIsFallbackAssistantEnabled; 129 } 130 131 /** 132 * Returns true if the current active assistant has notification listener permissions. 133 */ assistantIsNotificationListener()134 public boolean assistantIsNotificationListener() { 135 if (!hasActiveAssistant()) { 136 if (Log.isLoggable(TAG, Log.DEBUG)) { 137 Log.d(TAG, "No active assistant was found."); 138 } 139 return false; 140 } 141 final String activeComponent = mAssistUtils.getActiveServiceComponentName() 142 .flattenToString(); 143 int slashIndex = activeComponent.indexOf("/"); 144 final String activePackage = activeComponent.substring(0, slashIndex); 145 146 final String listeners = Settings.Secure.getStringForUser(mContext.getContentResolver(), 147 Settings.Secure.ENABLED_NOTIFICATION_LISTENERS, ActivityManager.getCurrentUser()); 148 149 if (Log.isLoggable(TAG, Log.DEBUG)) { 150 Log.d(TAG, "Current user: " + ActivityManager.getCurrentUser() 151 + " has active voice service: " + activePackage + " and enabled notification " 152 + " listeners: " + listeners); 153 } 154 155 if (listeners != null) { 156 for (String listener : Arrays.asList(listeners.split(":"))) { 157 if (listener.contains(activePackage)) { 158 return true; 159 } 160 } 161 } 162 Log.w(TAG, "No notification listeners found for assistant: " + activeComponent); 163 return false; 164 } 165 166 /** 167 * Checks whether the notification is a car-compatible messaging notification. 168 * 169 * @param sbn The notification being checked. 170 * @return true if the notification is a car-compatible messaging notification. 171 */ isCarCompatibleMessagingNotification(StatusBarNotification sbn)172 public static boolean isCarCompatibleMessagingNotification(StatusBarNotification sbn) { 173 Notification notification = sbn.getNotification(); 174 return hasMessagingStyle(notification) 175 && hasRequiredAssistantCallbacks(notification) 176 && ((getReplyAction(notification) == null) 177 || replyCallbackHasRemoteInput(notification)) 178 && assistantCallbacksShowNoUi(notification); 179 } 180 181 /** Returns true if the semantic action provided can be supported. */ isSupportedSemanticAction(int semanticAction)182 public static boolean isSupportedSemanticAction(int semanticAction) { 183 return SUPPORTED_SEMANTIC_ACTIONS.contains(semanticAction); 184 } 185 186 /** 187 * Returns true if the notification has a messaging style. 188 * <p/> 189 * This is the case if the notification in question was provided an instance of 190 * {@link Notification.MessagingStyle} (or an instance of 191 * {@link NotificationCompat.MessagingStyle} if {@link NotificationCompat} was used). 192 */ hasMessagingStyle(Notification notification)193 private static boolean hasMessagingStyle(Notification notification) { 194 return NotificationCompat.MessagingStyle 195 .extractMessagingStyleFromNotification(notification) != null; 196 } 197 198 /** 199 * Returns true if the notification has the required Assistant callbacks to be considered 200 * a car-compatible messaging notification. The callbacks must be unambiguous, therefore false 201 * is returned if multiple callbacks exist for any semantic action that is supported. 202 */ hasRequiredAssistantCallbacks(Notification notification)203 private static boolean hasRequiredAssistantCallbacks(Notification notification) { 204 List<Integer> semanticActionList = getAllActions(notification) 205 .stream() 206 .map(NotificationCompat.Action::getSemanticAction) 207 .filter(REQUIRED_SEMANTIC_ACTIONS::contains) 208 .collect(Collectors.toList()); 209 Set<Integer> semanticActionSet = new HashSet<>(semanticActionList); 210 return semanticActionList.size() == semanticActionSet.size() 211 && semanticActionSet.containsAll(REQUIRED_SEMANTIC_ACTIONS); 212 } 213 214 /** Retrieves visible and invisible {@link Action}s from the {@link Notification}. */ getAllActions(Notification notification)215 public static List<Action> getAllActions(Notification notification) { 216 List<Action> actions = new ArrayList<>( 217 NotificationCompat.getInvisibleActions(notification) 218 ); 219 int visibleActionCount = NotificationCompat.getActionCount(notification); 220 for (int i = 0; i < visibleActionCount; i++) { 221 actions.add(NotificationCompat.getAction(notification, i)); 222 } 223 return actions; 224 } 225 226 /** 227 * Retrieves the {@link NotificationCompat.Action} containing the 228 * {@link NotificationCompat.Action#SEMANTIC_ACTION_MARK_AS_READ} semantic action. 229 */ 230 @Nullable getMarkAsReadAction(Notification notification)231 public static NotificationCompat.Action getMarkAsReadAction(Notification notification) { 232 for (NotificationCompat.Action action : getAllActions(notification)) { 233 if (action.getSemanticAction() 234 == NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ) { 235 return action; 236 } 237 } 238 return null; 239 } 240 241 /** 242 * Retrieves the {@link NotificationCompat.Action} containing the 243 * {@link NotificationCompat.Action#SEMANTIC_ACTION_MUTE semantic action. 244 */ 245 @Nullable getMuteAction(Notification notification)246 public static NotificationCompat.Action getMuteAction(Notification notification) { 247 for (NotificationCompat.Action action : getAllActions(notification)) { 248 if (action.getSemanticAction() == Action.SEMANTIC_ACTION_MUTE) { 249 return action; 250 } 251 } 252 return null; 253 } 254 255 /** 256 * Retrieves the {@link NotificationCompat.Action} containing the 257 * {@link NotificationCompat.Action#SEMANTIC_ACTION_REPLY} semantic action. 258 */ 259 @Nullable getReplyAction(Notification notification)260 private static NotificationCompat.Action getReplyAction(Notification notification) { 261 for (NotificationCompat.Action action : getAllActions(notification)) { 262 if (action.getSemanticAction() 263 == NotificationCompat.Action.SEMANTIC_ACTION_REPLY) { 264 return action; 265 } 266 } 267 return null; 268 } 269 270 /** 271 * Returns true if the reply callback has at least one {@link RemoteInput}. 272 * <p/> 273 * Precondition: There exists only one reply callback. 274 */ replyCallbackHasRemoteInput(Notification notification)275 private static boolean replyCallbackHasRemoteInput(Notification notification) { 276 return getAllActions(notification) 277 .stream() 278 .filter(action -> action.getSemanticAction() == SEMANTIC_ACTION_REPLY) 279 .map(NotificationCompat.Action::getRemoteInputs) 280 .filter(Objects::nonNull) 281 .anyMatch(remoteInputs -> remoteInputs.length > 0); 282 } 283 284 /** Returns true if all Assistant callbacks indicate that they show no UI, false otherwise. */ assistantCallbacksShowNoUi(final Notification notification)285 private static boolean assistantCallbacksShowNoUi(final Notification notification) { 286 return getAllActions(notification) 287 .stream() 288 .filter(Objects::nonNull) 289 .filter(action -> SUPPORTED_SEMANTIC_ACTIONS.contains(action.getSemanticAction())) 290 .noneMatch(NotificationCompat.Action::getShowsUserInterface); 291 } 292 293 /** 294 * Requests a given action from the current active Assistant. 295 * 296 * @param sbn the notification payload to deliver to assistant 297 * @param voiceAction must be a valid {@link CarVoiceInteractionSession} VOICE_ACTION 298 * @param callback the callback to issue on success/error 299 */ requestAssistantVoiceAction(StatusBarNotification sbn, String voiceAction, ActionRequestCallback callback)300 public void requestAssistantVoiceAction(StatusBarNotification sbn, String voiceAction, 301 ActionRequestCallback callback) { 302 if (!isCarCompatibleMessagingNotification(sbn)) { 303 Log.w(TAG, "Assistant action requested for non-compatible notification."); 304 callback.onResult(ActionRequestCallback.RESULT_FAILED); 305 return; 306 } 307 308 switch (voiceAction) { 309 case CarVoiceInteractionSession.VOICE_ACTION_READ_NOTIFICATION: 310 readMessageNotification(sbn, callback); 311 return; 312 case CarVoiceInteractionSession.VOICE_ACTION_REPLY_NOTIFICATION: 313 replyMessageNotification(sbn, callback); 314 return; 315 default: 316 Log.w(TAG, "Requested Assistant action for unsupported semantic action."); 317 callback.onResult(ActionRequestCallback.RESULT_FAILED); 318 return; 319 } 320 } 321 322 /** 323 * Requests a read action for the notification from the current active Assistant. 324 * If the Assistant cannot handle the request, a fallback implementation will attempt to 325 * handle it. 326 * 327 * @param sbn the notification to deliver as the payload 328 * @param callback the callback to issue on success/error 329 */ readMessageNotification(StatusBarNotification sbn, ActionRequestCallback callback)330 private void readMessageNotification(StatusBarNotification sbn, 331 ActionRequestCallback callback) { 332 Bundle args = BundleBuilder.buildAssistantReadBundle(sbn); 333 String action = CarVoiceInteractionSession.VOICE_ACTION_READ_NOTIFICATION; 334 335 requestAction(action, sbn, args, callback); 336 } 337 338 /** 339 * Requests a reply action for the notification from the current active Assistant. 340 * If the Assistant cannot handle the request, a fallback implementation will attempt to 341 * handle it. 342 * 343 * @param sbn the notification to deliver as the payload 344 * @param callback the callback to issue on success/error 345 */ replyMessageNotification(StatusBarNotification sbn, ActionRequestCallback callback)346 private void replyMessageNotification(StatusBarNotification sbn, 347 ActionRequestCallback callback) { 348 Bundle args = BundleBuilder.buildAssistantReplyBundle(sbn); 349 String action = CarVoiceInteractionSession.VOICE_ACTION_REPLY_NOTIFICATION; 350 351 requestAction(action, sbn, args, callback); 352 } 353 requestAction(String action, StatusBarNotification sbn, Bundle payloadArguments, ActionRequestCallback callback)354 private void requestAction(String action, StatusBarNotification sbn, Bundle payloadArguments, 355 ActionRequestCallback callback) { 356 357 if (!hasActiveAssistant()) { 358 if (mIsFallbackAssistantEnabled) { 359 handleFallback(sbn, action, callback); 360 } else { 361 // If there is no active assistant, and fallback assistant is not enabled, then 362 // there is nothing for us to do. 363 callback.onResult(ActionRequestCallback.RESULT_FAILED); 364 } 365 return; 366 } 367 368 if (!assistantIsNotificationListener()) { 369 if (mIsFallbackAssistantEnabled) { 370 handleFallback(sbn, action, callback); 371 } else { 372 // If there is an active assistant, alert them to request permissions. 373 Bundle handleExceptionBundle = BundleBuilder 374 .buildAssistantHandleExceptionBundle( 375 EXCEPTION_NOTIFICATION_LISTENER_PERMISSIONS_MISSING, 376 /* fallbackAssistantEnabled */ false); 377 fireAssistantAction(CarVoiceInteractionSession.VOICE_ACTION_HANDLE_EXCEPTION, 378 handleExceptionBundle, callback); 379 } 380 return; 381 } 382 383 fireAssistantAction(action, payloadArguments, callback); 384 } 385 fireAssistantAction(String action, Bundle payloadArguments, ActionRequestCallback callback)386 private void fireAssistantAction(String action, Bundle payloadArguments, 387 ActionRequestCallback callback) { 388 IVoiceActionCheckCallback actionCheckCallback = new IVoiceActionCheckCallback.Stub() { 389 @Override 390 public void onComplete(List<String> supportedActions) { 391 String resultState = ActionRequestCallback.RESULT_FAILED; 392 if (supportedActions != null && supportedActions.contains(action)) { 393 if (Log.isLoggable(TAG, Log.DEBUG)) { 394 Log.d(TAG, "Launching active Assistant for action: " + action); 395 } 396 if (mAssistUtils.showSessionForActiveService(payloadArguments, 397 SHOW_SOURCE_NOTIFICATION, null, null)) { 398 resultState = ActionRequestCallback.RESULT_SUCCESS; 399 } 400 } else { 401 Log.w(TAG, "Active Assistant does not support voice action: " + action); 402 } 403 callback.onResult(resultState); 404 } 405 }; 406 407 Set<String> actionSet = new HashSet<>(Collections.singletonList(action)); 408 mAssistUtils.getActiveServiceSupportedActions(actionSet, actionCheckCallback); 409 } 410 handleFallback(StatusBarNotification sbn, String action, ActionRequestCallback callback)411 private void handleFallback(StatusBarNotification sbn, String action, 412 ActionRequestCallback callback) { 413 if (mFallbackAssistant == null) { 414 return; 415 } 416 417 FallbackAssistant.Listener listener = new FallbackAssistant.Listener() { 418 @Override 419 public void onMessageRead(boolean hasError) { 420 // Tracks if the FallbackAssistant successfully handled the action. 421 final String fallbackActionResult = hasError ? ActionRequestCallback.RESULT_FAILED 422 : ActionRequestCallback.RESULT_SUCCESS; 423 if (hasActiveAssistant()) { 424 // If there is an active assistant, alert them to request permissions. 425 Bundle handleExceptionBundle = BundleBuilder 426 .buildAssistantHandleExceptionBundle( 427 EXCEPTION_NOTIFICATION_LISTENER_PERMISSIONS_MISSING, 428 /* fallbackAssistantEnabled */ true); 429 fireAssistantAction(CarVoiceInteractionSession.VOICE_ACTION_HANDLE_EXCEPTION, 430 handleExceptionBundle, new ActionRequestCallback() { 431 @Override 432 public void onResult(String requestActionFromAssistantResult) { 433 if (fallbackActionResult.equals( 434 ActionRequestCallback.RESULT_FAILED) 435 && requestActionFromAssistantResult 436 == ActionRequestCallback.RESULT_SUCCESS) { 437 // Only change the callback.ResultState if fallback failed, 438 // and assistant session is shown. 439 callback.onResult( 440 ActionRequestCallback 441 .RESULT_FAILED_WITH_ERROR_HANDLED); 442 } else { 443 callback.onResult(fallbackActionResult); 444 } 445 } 446 }); 447 } else { 448 callback.onResult(fallbackActionResult); 449 } 450 } 451 }; 452 453 switch (action) { 454 case CarVoiceInteractionSession.VOICE_ACTION_READ_NOTIFICATION: 455 mFallbackAssistant.handleReadAction(sbn, listener); 456 break; 457 case CarVoiceInteractionSession.VOICE_ACTION_REPLY_NOTIFICATION: 458 mFallbackAssistant.handleErrorMessage(mErrorMessage, listener); 459 break; 460 default: 461 Log.w(TAG, "Requested unsupported FallbackAssistant action."); 462 callback.onResult(ActionRequestCallback.RESULT_FAILED); 463 return; 464 } 465 } 466 } 467