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