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