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