blob: 1f9d9158fd5cc9ad745347fdb25d9f9c645bcc62 [file] [log] [blame]
/*
* Copyright (C) 2019 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.car.assist.client;
import static android.app.Notification.Action.SEMANTIC_ACTION_MARK_AS_READ;
import static android.app.Notification.Action.SEMANTIC_ACTION_REPLY;
import static android.service.voice.VoiceInteractionSession.SHOW_SOURCE_NOTIFICATION;
import static com.android.car.assist.CarVoiceInteractionSession.EXCEPTION_NOTIFICATION_LISTENER_PERMISSIONS_MISSING;
import android.annotation.Nullable;
import android.app.ActivityManager;
import android.app.Notification;
import android.app.RemoteInput;
import android.content.Context;
import android.os.Bundle;
import android.provider.Settings;
import android.service.notification.StatusBarNotification;
import android.util.Log;
import androidx.annotation.StringDef;
import androidx.core.app.NotificationCompat;
import com.android.car.assist.CarVoiceInteractionSession;
import com.android.internal.app.AssistUtils;
import com.android.internal.app.IVoiceActionCheckCallback;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
/**
* Util class providing helper methods to interact with the current active voice service,
* while ensuring that the active voice service has the required permissions.
*/
public class CarAssistUtils {
public static final String TAG = "CarAssistUtils";
private static final List<Integer> REQUIRED_SEMANTIC_ACTIONS = Collections.unmodifiableList(
Arrays.asList(
SEMANTIC_ACTION_MARK_AS_READ
)
);
private static final List<Integer> SUPPORTED_SEMANTIC_ACTIONS = Collections.unmodifiableList(
Arrays.asList(
SEMANTIC_ACTION_MARK_AS_READ,
SEMANTIC_ACTION_REPLY
)
);
private final Context mContext;
private final AssistUtils mAssistUtils;
private final FallbackAssistant mFallbackAssistant;
private final String mErrorMessage;
private final boolean mIsFallbackAssistantEnabled;
/** Interface used to receive callbacks from voice action requests. */
public interface ActionRequestCallback {
/**
* The action was successfully completed either by the active or fallback assistant.
**/
String RESULT_SUCCESS = "SUCCESS";
/**
* The action was not successfully completed, but the active assistant has been prompted to
* alert the user of this error and handle it. The caller of this callback is recommended
* to NOT alert the user of this error again.
*/
String RESULT_FAILED_WITH_ERROR_HANDLED = "FAILED_WITH_ERROR_HANDLED";
/**
* The action has not been successfully completed, and the error has not been handled.
**/
String RESULT_FAILED = "FAILED";
/**
* The list of result states.
*/
@StringDef({RESULT_FAILED, RESULT_FAILED_WITH_ERROR_HANDLED, RESULT_SUCCESS})
@interface ResultState {
}
/** Callback containing the result of completing the voice action request. */
void onResult(@ResultState String state);
}
public CarAssistUtils(Context context) {
mContext = context;
mAssistUtils = new AssistUtils(context);
mFallbackAssistant = new FallbackAssistant(context);
mErrorMessage = context.getString(R.string.assist_action_failed_toast);
mIsFallbackAssistantEnabled =
context.getResources().getBoolean(R.bool.config_enableFallbackAssistant);
}
/**
* @return {@code true} if there is an active assistant.
*/
public boolean hasActiveAssistant() {
return mAssistUtils.getActiveServiceComponentName() != null;
}
/**
* Returns true if the current active assistant has notification listener permissions.
*/
public boolean assistantIsNotificationListener() {
if (!hasActiveAssistant()) {
if (Log.isLoggable(TAG, Log.DEBUG)) {
Log.d(TAG, "No active assistant was found.");
}
return false;
}
final String activeComponent = mAssistUtils.getActiveServiceComponentName()
.flattenToString();
int slashIndex = activeComponent.indexOf("/");
final String activePackage = activeComponent.substring(0, slashIndex);
final String listeners = Settings.Secure.getStringForUser(mContext.getContentResolver(),
Settings.Secure.ENABLED_NOTIFICATION_LISTENERS, ActivityManager.getCurrentUser());
if (Log.isLoggable(TAG, Log.DEBUG)) {
Log.d(TAG, "Current user: " + ActivityManager.getCurrentUser()
+ " has active voice service: " + activePackage + " and enabled notification "
+ " listeners: " + listeners);
}
if (listeners != null) {
for (String listener : Arrays.asList(listeners.split(":"))) {
if (listener.contains(activePackage)) {
return true;
}
}
}
Log.w(TAG, "No notification listeners found for assistant: " + activeComponent);
return false;
}
/**
* Checks whether the notification is a car-compatible messaging notification.
*
* @param sbn The notification being checked.
* @return true if the notification is a car-compatible messaging notification.
*/
public static boolean isCarCompatibleMessagingNotification(StatusBarNotification sbn) {
return hasMessagingStyle(sbn)
&& hasRequiredAssistantCallbacks(sbn)
&& ((getReplyAction(sbn.getNotification()) == null)
|| replyCallbackHasRemoteInput(sbn))
&& assistantCallbacksShowNoUi(sbn);
}
/** Returns true if the semantic action provided can be supported. */
public static boolean isSupportedSemanticAction(int semanticAction) {
return SUPPORTED_SEMANTIC_ACTIONS.contains(semanticAction);
}
/**
* Returns true if the notification has a messaging style.
* <p/>
* This is the case if the notification in question was provided an instance of
* {@link Notification.MessagingStyle} (or an instance of
* {@link NotificationCompat.MessagingStyle} if {@link NotificationCompat} was used).
*/
private static boolean hasMessagingStyle(StatusBarNotification sbn) {
return NotificationCompat.MessagingStyle
.extractMessagingStyleFromNotification(sbn.getNotification()) != null;
}
/**
* Returns true if the notification has the required Assistant callbacks to be considered
* a car-compatible messaging notification. The callbacks must be unambiguous, therefore false
* is returned if multiple callbacks exist for any semantic action that is supported.
*/
private static boolean hasRequiredAssistantCallbacks(StatusBarNotification sbn) {
List<Integer> semanticActionList = getAllActions(sbn.getNotification())
.stream()
.map(NotificationCompat.Action::getSemanticAction)
.filter(REQUIRED_SEMANTIC_ACTIONS::contains)
.collect(Collectors.toList());
Set<Integer> semanticActionSet = new HashSet<>(semanticActionList);
return semanticActionList.size() == semanticActionSet.size()
&& semanticActionSet.containsAll(REQUIRED_SEMANTIC_ACTIONS);
}
/** Retrieves all visible and invisible {@link Action}s from the {@link #notification}. */
public static List<NotificationCompat.Action> getAllActions(Notification notification) {
List<NotificationCompat.Action> actions = new ArrayList<>();
actions.addAll(NotificationCompat.getInvisibleActions(notification));
for (int i = 0; i < NotificationCompat.getActionCount(notification); i++) {
actions.add(NotificationCompat.getAction(notification, i));
}
return actions;
}
/**
* Retrieves the {@link NotificationCompat.Action} containing the
* {@link NotificationCompat.Action#SEMANTIC_ACTION_MARK_AS_READ} semantic action.
*/
@Nullable
public static NotificationCompat.Action getMarkAsReadAction(Notification notification) {
for (NotificationCompat.Action action : getAllActions(notification)) {
if (action.getSemanticAction()
== NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ) {
return action;
}
}
return null;
}
/**
* Retrieves the {@link NotificationCompat.Action} containing the
* {@link NotificationCompat.Action#SEMANTIC_ACTION_REPLY} semantic action.
*/
@Nullable
private static NotificationCompat.Action getReplyAction(Notification notification) {
for (NotificationCompat.Action action : getAllActions(notification)) {
if (action.getSemanticAction()
== NotificationCompat.Action.SEMANTIC_ACTION_REPLY) {
return action;
}
}
return null;
}
/**
* Returns true if the reply callback has at least one {@link RemoteInput}.
* <p/>
* Precondition: There exists only one reply callback.
*/
private static boolean replyCallbackHasRemoteInput(StatusBarNotification sbn) {
return Arrays.stream(sbn.getNotification().actions)
.filter(action -> action.getSemanticAction() == SEMANTIC_ACTION_REPLY)
.map(Notification.Action::getRemoteInputs)
.filter(Objects::nonNull)
.anyMatch(remoteInputs -> remoteInputs.length > 0);
}
/** Returns true if all Assistant callbacks indicate that they show no UI, false otherwise. */
private static boolean assistantCallbacksShowNoUi(StatusBarNotification sbn) {
final Notification notification = sbn.getNotification();
return IntStream.range(0, notification.actions.length)
.mapToObj(i -> NotificationCompat.getAction(notification, i))
.filter(Objects::nonNull)
.filter(action -> SUPPORTED_SEMANTIC_ACTIONS.contains(action.getSemanticAction()))
.noneMatch(NotificationCompat.Action::getShowsUserInterface);
}
/**
* Requests a given action from the current active Assistant.
*
* @param sbn the notification payload to deliver to assistant
* @param voiceAction must be a valid {@link CarVoiceInteractionSession} VOICE_ACTION
* @param callback the callback to issue on success/error
*/
public void requestAssistantVoiceAction(StatusBarNotification sbn, String voiceAction,
ActionRequestCallback callback) {
if (!isCarCompatibleMessagingNotification(sbn)) {
Log.w(TAG, "Assistant action requested for non-compatible notification.");
callback.onResult(ActionRequestCallback.RESULT_FAILED);
return;
}
switch (voiceAction) {
case CarVoiceInteractionSession.VOICE_ACTION_READ_NOTIFICATION:
readMessageNotification(sbn, callback);
return;
case CarVoiceInteractionSession.VOICE_ACTION_REPLY_NOTIFICATION:
replyMessageNotification(sbn, callback);
return;
default:
Log.w(TAG, "Requested Assistant action for unsupported semantic action.");
callback.onResult(ActionRequestCallback.RESULT_FAILED);
return;
}
}
/**
* Requests a read action for the notification from the current active Assistant.
* If the Assistant cannot handle the request, a fallback implementation will attempt to
* handle it.
*
* @param sbn the notification to deliver as the payload
* @param callback the callback to issue on success/error
*/
private void readMessageNotification(StatusBarNotification sbn,
ActionRequestCallback callback) {
Bundle args = BundleBuilder.buildAssistantReadBundle(sbn);
String action = CarVoiceInteractionSession.VOICE_ACTION_READ_NOTIFICATION;
requestAction(action, sbn, args, callback);
}
/**
* Requests a reply action for the notification from the current active Assistant.
* If the Assistant cannot handle the request, a fallback implementation will attempt to
* handle it.
*
* @param sbn the notification to deliver as the payload
* @param callback the callback to issue on success/error
*/
private void replyMessageNotification(StatusBarNotification sbn,
ActionRequestCallback callback) {
Bundle args = BundleBuilder.buildAssistantReplyBundle(sbn);
String action = CarVoiceInteractionSession.VOICE_ACTION_REPLY_NOTIFICATION;
requestAction(action, sbn, args, callback);
}
private void requestAction(String action, StatusBarNotification sbn, Bundle payloadArguments,
ActionRequestCallback callback) {
if (!hasActiveAssistant()) {
if (mIsFallbackAssistantEnabled) {
handleFallback(sbn, action, callback);
} else {
// If there is no active assistant, and fallback assistant is not enabled, then
// there is nothing for us to do.
callback.onResult(ActionRequestCallback.RESULT_FAILED);
}
return;
}
if (!assistantIsNotificationListener()) {
if (mIsFallbackAssistantEnabled) {
handleFallback(sbn, action, callback);
} else {
// If there is an active assistant, alert them to request permissions.
Bundle handleExceptionBundle = BundleBuilder
.buildAssistantHandleExceptionBundle(
EXCEPTION_NOTIFICATION_LISTENER_PERMISSIONS_MISSING,
/* fallbackAssistantEnabled */ false);
fireAssistantAction(CarVoiceInteractionSession.VOICE_ACTION_HANDLE_EXCEPTION,
handleExceptionBundle, callback);
}
return;
}
fireAssistantAction(action, payloadArguments, callback);
}
private void fireAssistantAction(String action, Bundle payloadArguments,
ActionRequestCallback callback) {
IVoiceActionCheckCallback actionCheckCallback = new IVoiceActionCheckCallback.Stub() {
@Override
public void onComplete(List<String> supportedActions) {
String resultState = ActionRequestCallback.RESULT_FAILED;
if (supportedActions != null && supportedActions.contains(action)) {
if (Log.isLoggable(TAG, Log.DEBUG)) {
Log.d(TAG, "Launching active Assistant for action: " + action);
}
if (mAssistUtils.showSessionForActiveService(payloadArguments,
SHOW_SOURCE_NOTIFICATION, null, null)) {
resultState = ActionRequestCallback.RESULT_SUCCESS;
}
} else {
Log.w(TAG, "Active Assistant does not support voice action: " + action);
}
callback.onResult(resultState);
}
};
Set<String> actionSet = new HashSet<>(Collections.singletonList(action));
mAssistUtils.getActiveServiceSupportedActions(actionSet, actionCheckCallback);
}
private void handleFallback(StatusBarNotification sbn, String action,
ActionRequestCallback callback) {
FallbackAssistant.Listener listener = new FallbackAssistant.Listener() {
@Override
public void onMessageRead(boolean hasError) {
// Tracks if the FallbackAssistant successfully handled the action.
final String fallbackActionResult = hasError ? ActionRequestCallback.RESULT_FAILED
: ActionRequestCallback.RESULT_SUCCESS;
if (hasActiveAssistant()) {
// If there is an active assistant, alert them to request permissions.
Bundle handleExceptionBundle = BundleBuilder
.buildAssistantHandleExceptionBundle(
EXCEPTION_NOTIFICATION_LISTENER_PERMISSIONS_MISSING,
/* fallbackAssistantEnabled */ true);
fireAssistantAction(CarVoiceInteractionSession.VOICE_ACTION_HANDLE_EXCEPTION,
handleExceptionBundle, new ActionRequestCallback() {
@Override
public void onResult(String requestActionFromAssistantResult) {
if (fallbackActionResult.equals(
ActionRequestCallback.RESULT_FAILED)
&& requestActionFromAssistantResult
== ActionRequestCallback.RESULT_SUCCESS) {
// Only change the callback.ResultState if fallback failed,
// and assistant session is shown.
callback.onResult(
ActionRequestCallback
.RESULT_FAILED_WITH_ERROR_HANDLED);
} else {
callback.onResult(fallbackActionResult);
}
}
});
} else {
callback.onResult(fallbackActionResult);
}
}
};
switch (action) {
case CarVoiceInteractionSession.VOICE_ACTION_READ_NOTIFICATION:
mFallbackAssistant.handleReadAction(sbn, listener);
break;
case CarVoiceInteractionSession.VOICE_ACTION_REPLY_NOTIFICATION:
mFallbackAssistant.handleErrorMessage(mErrorMessage, listener);
break;
default:
Log.w(TAG, "Requested unsupported FallbackAssistant action.");
callback.onResult(ActionRequestCallback.RESULT_FAILED);
return;
}
}
}