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 
17 package com.android.car.assist.client;
18 
19 
20 import android.annotation.Nullable;
21 import android.app.ActivityManager;
22 import android.app.Notification;
23 import android.app.Notification.MessagingStyle.Message;
24 import android.app.PendingIntent;
25 import android.app.Person;
26 import android.content.Context;
27 import android.content.Intent;
28 import android.os.Parcelable;
29 import android.service.notification.StatusBarNotification;
30 import android.util.Log;
31 import android.widget.Toast;
32 
33 import androidx.core.app.NotificationCompat;
34 
35 import com.android.car.assist.client.tts.TextToSpeechHelper;
36 
37 import java.util.ArrayList;
38 import java.util.Collections;
39 import java.util.HashMap;
40 import java.util.List;
41 import java.util.Map;
42 
43 /**
44  * Handles Assistant request fallbacks in the case that Assistant cannot fulfill the request for
45  * any given reason.
46  * <p/>
47  * Simply reads out the notification messages for read requests, and speaks out
48  * an error message for other requests.
49  */
50 public class FallbackAssistant {
51 
52     private static final String TAG = FallbackAssistant.class.getSimpleName();
53 
54     private final Context mContext;
55     private final TextToSpeechHelper mTextToSpeechHelper;
56     private final RequestIdGenerator mRequestIdGenerator;
57     private Map<Long, ActionRequestInfo> mRequestIdToActionRequestInfo = new HashMap<>();
58     // String that means "says", to be used when reading out a message (i.e. <Sender> says
59     // <Message).
60     private final String mVerbForSays;
61 
62     private final TextToSpeechHelper.Listener mListener = new TextToSpeechHelper.Listener() {
63         @Override
64         public void onTextToSpeechStarted(long requestId) {
65             if (Log.isLoggable(TAG, Log.DEBUG)) {
66                 Log.d(TAG, "onTextToSpeechStarted");
67             }
68         }
69 
70         @Override
71         public void onTextToSpeechStopped(long requestId, boolean error) {
72             if (Log.isLoggable(TAG, Log.DEBUG)) {
73                 Log.d(TAG, "onTextToSpeechStopped");
74             }
75 
76             if (error) {
77                 Toast.makeText(mContext, mContext.getString(R.string.assist_action_failed_toast),
78                         Toast.LENGTH_LONG).show();
79             }
80             finishAction(requestId, error);
81         }
82     };
83 
84     /** Listener to allow clients to be alerted when their requested message has been read. **/
85     public interface Listener {
86         /**
87          * Called after the TTS engine has finished reading aloud the message.
88          */
onMessageRead(boolean hasError)89         void onMessageRead(boolean hasError);
90     }
91 
FallbackAssistant(Context context)92     public FallbackAssistant(Context context) {
93         mContext = context;
94         mTextToSpeechHelper = new TextToSpeechHelper(context, mListener);
95         mRequestIdGenerator = new RequestIdGenerator();
96         mVerbForSays = mContext.getString(R.string.says);
97     }
98 
99     /**
100      * Handles a fallback read action by reading all messages in the notification.
101      *
102      * @param sbn the payload notification from which to extract messages from
103      */
handleReadAction(StatusBarNotification sbn, Listener listener)104     public void handleReadAction(StatusBarNotification sbn, Listener listener) {
105         if (mTextToSpeechHelper.isSpeaking()) {
106             mTextToSpeechHelper.requestStop();
107         }
108 
109         Parcelable[] messagesBundle = sbn.getNotification().extras
110                 .getParcelableArray(Notification.EXTRA_MESSAGES);
111 
112         if (messagesBundle == null || messagesBundle.length == 0) {
113             listener.onMessageRead(/* hasError= */ true);
114             return;
115         }
116 
117         List<CharSequence> messages = new ArrayList<>();
118         List<Message> messageList = Message.getMessagesFromBundleArray(messagesBundle);
119         if (messageList == null || messageList.isEmpty()) {
120             Log.w(TAG, "No messages could be extracted from the bundle");
121             listener.onMessageRead(/* hasError= */ true);
122             return;
123         }
124 
125         Person previousSender = messageList.get(0).getSenderPerson();
126         if (previousSender != null) {
127             messages.add(previousSender.getName());
128             messages.add(mVerbForSays);
129         }
130         for (Message message : messageList) {
131             if (!message.getSenderPerson().equals(previousSender)) {
132                 messages.add(message.getSenderPerson().getName());
133                 messages.add(mVerbForSays);
134                 previousSender = message.getSenderPerson();
135             }
136             messages.add(message.getText());
137         }
138 
139         long requestId = mRequestIdGenerator.generateRequestId();
140 
141         if (mTextToSpeechHelper.requestPlay(messages, requestId)) {
142             if (Log.isLoggable(TAG, Log.DEBUG)) {
143                 Log.d(TAG, "Requesting TTS to read message with requestId: " + requestId);
144             }
145             mRequestIdToActionRequestInfo.put(requestId, new ActionRequestInfo(sbn, listener));
146         } else {
147             listener.onMessageRead(/* hasError= */ true);
148         }
149     }
150 
151     /**
152      * Handles generic (non-read) actions by reading out an error message.
153      *
154      * @param errorMessage the error message to read out
155      */
handleErrorMessage(CharSequence errorMessage, Listener listener)156     public void handleErrorMessage(CharSequence errorMessage, Listener listener) {
157         if (mTextToSpeechHelper.isSpeaking()) {
158             mTextToSpeechHelper.requestStop();
159         }
160 
161         long requestId = mRequestIdGenerator.generateRequestId();
162         if (mTextToSpeechHelper.requestPlay(Collections.singletonList(errorMessage),
163                 requestId)) {
164             if (Log.isLoggable(TAG, Log.DEBUG)) {
165                 Log.d(TAG, "Requesting TTS to read error with requestId: " + requestId);
166             }
167             mRequestIdToActionRequestInfo.put(requestId, new ActionRequestInfo(
168                     /* statusBarNotification= */ null,
169                     listener));
170         } else {
171             listener.onMessageRead(/* hasError= */ true);
172         }
173     }
174 
finishAction(long requestId, boolean hasError)175     private void finishAction(long requestId, boolean hasError) {
176         if (!mRequestIdToActionRequestInfo.containsKey(requestId)) {
177             Log.w(TAG, "No actionRequestInfo found for requestId: " + requestId);
178             return;
179         }
180 
181         ActionRequestInfo info = mRequestIdToActionRequestInfo.remove(requestId);
182 
183         if (info.getStatusBarNotification() != null && !hasError) {
184             sendMarkAsReadIntent(info.getStatusBarNotification());
185         }
186 
187         info.getListener().onMessageRead(hasError);
188     }
189 
sendMarkAsReadIntent(StatusBarNotification sbn)190     private void sendMarkAsReadIntent(StatusBarNotification sbn) {
191         NotificationCompat.Action markAsReadAction = CarAssistUtils.getMarkAsReadAction(
192                 sbn.getNotification());
193         boolean isDebugLoggable = Log.isLoggable(TAG, Log.DEBUG);
194 
195         if (markAsReadAction != null) {
196             if (sendPendingIntent(markAsReadAction.getActionIntent(),
197                     null /* resultIntent */) != ActivityManager.START_SUCCESS
198                     && isDebugLoggable) {
199                 Log.d(TAG, "Could not relay mark as read event to the messaging app.");
200             }
201         } else if (isDebugLoggable) {
202             Log.d(TAG, "Car compat message notification has no mark as read action: "
203                     + sbn.getKey());
204         }
205     }
206 
sendPendingIntent(PendingIntent pendingIntent, Intent resultIntent)207     private int sendPendingIntent(PendingIntent pendingIntent, Intent resultIntent) {
208         try {
209             return pendingIntent.sendAndReturnResult(/* context= */ mContext, /* code= */ 0,
210                     /* intent= */ resultIntent, /* onFinished= */null,
211                     /* handler= */ null, /* requiredPermissions= */ null,
212                     /* options= */ null);
213         } catch (PendingIntent.CanceledException e) {
214             // Do not take down the app over this
215             Log.w(TAG, "Sending contentIntent failed: " + e);
216             return ActivityManager.START_ABORTED;
217         }
218     }
219 
220     /** Helper class that generates unique IDs per TTS request. **/
221     private class RequestIdGenerator {
222         private long mCounter;
223 
RequestIdGenerator()224         RequestIdGenerator() {
225             mCounter = 0;
226         }
227 
generateRequestId()228         public long generateRequestId() {
229             return ++mCounter;
230         }
231     }
232 
233     /**
234      * Contains all of the information needed to start and finish actions supported by the
235      * FallbackAssistant.
236      **/
237     private class ActionRequestInfo {
238         private final StatusBarNotification mStatusBarNotification;
239         private final Listener mListener;
240 
ActionRequestInfo(@ullable StatusBarNotification statusBarNotification, Listener listener)241         ActionRequestInfo(@Nullable StatusBarNotification statusBarNotification,
242                 Listener listener) {
243             mStatusBarNotification = statusBarNotification;
244             mListener = listener;
245         }
246 
247         @Nullable
getStatusBarNotification()248         StatusBarNotification getStatusBarNotification() {
249             return mStatusBarNotification;
250         }
251 
getListener()252         Listener getListener() {
253             return mListener;
254         }
255     }
256 }
257