1 /*
2  * Copyright (C) 2020 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.messenger.core.util;
17 
18 import static com.android.car.assist.CarVoiceInteractionSession.KEY_ACTION;
19 import static com.android.car.assist.CarVoiceInteractionSession.KEY_CONVERSATION;
20 import static com.android.car.assist.CarVoiceInteractionSession.KEY_DEVICE_ADDRESS;
21 import static com.android.car.assist.CarVoiceInteractionSession.KEY_DEVICE_NAME;
22 import static com.android.car.assist.CarVoiceInteractionSession.KEY_NOTIFICATION;
23 import static com.android.car.assist.CarVoiceInteractionSession.KEY_PHONE_NUMBER;
24 import static com.android.car.assist.CarVoiceInteractionSession.KEY_SEND_PENDING_INTENT;
25 import static com.android.car.assist.CarVoiceInteractionSession.VOICE_ACTION_READ_CONVERSATION;
26 import static com.android.car.assist.CarVoiceInteractionSession.VOICE_ACTION_READ_NOTIFICATION;
27 import static com.android.car.assist.CarVoiceInteractionSession.VOICE_ACTION_REPLY_CONVERSATION;
28 import static com.android.car.assist.CarVoiceInteractionSession.VOICE_ACTION_REPLY_NOTIFICATION;
29 import static com.android.car.assist.CarVoiceInteractionSession.VOICE_ACTION_SEND_SMS;
30 import static com.android.car.messenger.core.shared.MessageConstants.ACTION_DIRECT_SEND;
31 import static com.android.car.messenger.core.shared.MessageConstants.ACTION_MARK_AS_READ;
32 import static com.android.car.messenger.core.shared.MessageConstants.ACTION_REPLY;
33 import static com.android.car.messenger.core.shared.MessageConstants.EXTRA_ACCOUNT_ID;
34 import static com.android.car.messenger.core.shared.MessageConstants.EXTRA_CONVERSATION_KEY;
35 
36 import android.app.Activity;
37 import android.app.PendingIntent;
38 import android.app.RemoteAction;
39 import android.app.RemoteInput;
40 import android.content.Context;
41 import android.content.Intent;
42 import android.graphics.drawable.Icon;
43 import android.os.Bundle;
44 import android.service.notification.StatusBarNotification;
45 import android.text.TextUtils;
46 
47 import androidx.annotation.NonNull;
48 import androidx.annotation.Nullable;
49 
50 import com.android.car.messenger.R;
51 import com.android.car.messenger.common.Conversation;
52 import com.android.car.messenger.common.Conversation.ConversationAction;
53 import com.android.car.messenger.common.Conversation.ConversationAction.ActionType;
54 import com.android.car.messenger.core.interfaces.AppFactory;
55 import com.android.car.messenger.core.models.UserAccount;
56 import com.android.car.messenger.core.service.MessengerService;
57 import com.android.car.messenger.core.shared.MessageConstants;
58 import com.android.car.messenger.core.shared.NotificationHandler;
59 
60 import java.util.ArrayList;
61 import java.util.List;
62 
63 /** Voice Util classes for requesting voice interactions and responding to voice actions */
64 public class VoiceUtil {
65 
66     /** Represents a null user account id */
67     private static final int NULL_ACCOUNT_ID = 0;
68 
VoiceUtil()69     private VoiceUtil() {}
70 
71     /** Requests Voice request to read a conversation */
voiceRequestReadConversation( @onNull Activity activity, @NonNull UserAccount userAccount, @NonNull Conversation conversation)72     public static void voiceRequestReadConversation(
73             @NonNull Activity activity,
74             @NonNull UserAccount userAccount,
75             @NonNull Conversation conversation) {
76         if (conversation.getMessages().isEmpty()) {
77             L.d("No messages to read from Conversation! Returning.");
78             return;
79         }
80         voiceRequestHelper(
81                 activity,
82                 conversation,
83                 userAccount,
84                 VOICE_ACTION_READ_CONVERSATION,
85                 VOICE_ACTION_READ_NOTIFICATION);
86     }
87 
88     /** Requests Voice request to reply to a conversation */
voiceRequestReplyConversation( @onNull Activity activity, @NonNull UserAccount userAccount, @NonNull Conversation conversation)89     public static void voiceRequestReplyConversation(
90             @NonNull Activity activity,
91             @NonNull UserAccount userAccount,
92             @NonNull Conversation conversation) {
93         voiceRequestHelper(
94                 activity,
95                 conversation,
96                 userAccount,
97                 VOICE_ACTION_REPLY_CONVERSATION,
98                 VOICE_ACTION_REPLY_NOTIFICATION);
99     }
100 
voiceRequestHelper( @onNull Activity activity, @NonNull Conversation conversation, @NonNull UserAccount userAccount, @NonNull String conversationAction, @NonNull String notificationAction)101     private static void voiceRequestHelper(
102             @NonNull Activity activity,
103             @NonNull Conversation conversation,
104             @NonNull UserAccount userAccount,
105             @NonNull String conversationAction,
106             @NonNull String notificationAction) {
107         Bundle args = new Bundle();
108         Conversation tapToReadConversation =
109                 createTapToReadConversation(conversation, userAccount.getId());
110         boolean isConversationSupported =
111                 activity.getResources().getBoolean(R.bool.ttr_conversation_supported);
112         if (isConversationSupported) {
113             // New API using generic Conversation class
114             // is currently limited in support by partner assistants and is being phased in.
115             args.putString(KEY_ACTION, conversationAction);
116             args.putBundle(KEY_CONVERSATION, tapToReadConversation.toBundle());
117         } else {
118             // Continue using legacy SBN
119             StatusBarNotification sbn =
120                     NotificationHandler.postNotificationForLegacyTapToRead(tapToReadConversation);
121             if (sbn == null) {
122                 L.e("Failed to convert Conversation to SBN for Legacy Tap To Read.");
123                 return;
124             }
125             args.putString(KEY_ACTION, notificationAction);
126             args.putParcelable(KEY_NOTIFICATION, sbn);
127         }
128 
129         activity.showAssist(args);
130     }
131 
132     /** Requests Voice request to start a generic compose voice interaction */
voiceRequestGenericCompose(Activity activity, UserAccount userAccount)133     public static void voiceRequestGenericCompose(Activity activity, UserAccount userAccount) {
134         Bundle bundle = new Bundle();
135         bundle.putString(KEY_ACTION, VOICE_ACTION_SEND_SMS);
136         bundle.putString(KEY_DEVICE_ADDRESS, userAccount.getIccId());
137         bundle.putString(KEY_DEVICE_NAME, userAccount.getName());
138         PendingIntent sendIntent =
139                 createServiceIntent(
140                         ACTION_DIRECT_SEND, /* conversationKey= */ null, userAccount.getId());
141         bundle.putParcelable(KEY_SEND_PENDING_INTENT, sendIntent);
142         activity.showAssist(bundle);
143     }
144 
145     /**
146      * Returns a new conversation containing the tap to read pending intents to be transferred over
147      * to the Voice Assistant.
148      *
149      * <p>The conversation object returned remained unmodified.
150      *
151      * <p>This is important to allow the Assistant have a different instance than the one that
152      * powers our UI. We can create new pending intents without modifying the instance the Assistant
153      * holds.
154      *
155      * @return new conversation instance with the same data and pending intents for tap to read.
156      */
createTapToReadConversation( Conversation conversation, int userAccountId)157     public static Conversation createTapToReadConversation(
158             Conversation conversation, int userAccountId) {
159         Context context = AppFactory.get().getContext();
160         String conversationKey = conversation.getId();
161         Conversation.Builder builder = conversation.toBuilder();
162 
163         final int replyIcon = R.drawable.car_ui_icon_reply;
164         final String replyString = context.getString(R.string.action_reply);
165         PendingIntent replyIntent =
166                 createServiceIntent(ACTION_REPLY, conversationKey, userAccountId);
167         ConversationAction replyAction =
168                 new ConversationAction(
169                         ActionType.ACTION_TYPE_REPLY,
170                         new RemoteAction(
171                                 Icon.createWithResource(context, replyIcon),
172                                 replyString,
173                                 replyString,
174                                 replyIntent),
175                         new RemoteInput.Builder(Intent.EXTRA_TEXT).build());
176 
177         final int markAsReadIcon = android.R.drawable.ic_media_play;
178         final String markAsReadString = context.getString(R.string.action_mark_as_read);
179         PendingIntent markAsReadIntent =
180                 createServiceIntent(ACTION_MARK_AS_READ, conversationKey, userAccountId);
181         ConversationAction markAsReadAction =
182                 new ConversationAction(
183                         ActionType.ACTION_TYPE_MARK_AS_READ,
184                         new RemoteAction(
185                                 Icon.createWithResource(context, markAsReadIcon),
186                                 markAsReadString,
187                                 markAsReadString,
188                                 markAsReadIntent),
189                         null);
190 
191         List<ConversationAction> actions = new ArrayList<>();
192         actions.add(replyAction);
193         actions.add(markAsReadAction);
194         builder.setActions(actions);
195         return builder.build();
196     }
197 
createServiceIntent( @onNull String action, @Nullable String conversationKey, int userAccountId)198     private static PendingIntent createServiceIntent(
199             @NonNull String action, @Nullable String conversationKey, int userAccountId) {
200         Context context = AppFactory.get().getContext();
201         Bundle bundle = new Bundle();
202         if (conversationKey != null) {
203             bundle.putString(EXTRA_CONVERSATION_KEY, conversationKey);
204         }
205         bundle.putInt(EXTRA_ACCOUNT_ID, userAccountId);
206         Intent intent =
207                 new Intent(context, MessengerService.class)
208                         .setAction(action)
209                         .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP)
210                         .setClass(context, MessengerService.class)
211                         .putExtras(bundle);
212 
213         int requestCode =
214                 (conversationKey == null) ? action.hashCode() : conversationKey.hashCode();
215         return PendingIntent.getForegroundService(
216                 context, requestCode, intent, PendingIntent.FLAG_MUTABLE);
217     }
218 
219     /** Sends a reply, meant to be used from a caller originating from voice input. */
directSend(Intent intent)220     public static void directSend(Intent intent) {
221         final CharSequence phoneNumber = intent.getCharSequenceExtra(KEY_PHONE_NUMBER);
222         final String iccId = intent.getStringExtra(KEY_DEVICE_ADDRESS);
223         final CharSequence message = intent.getCharSequenceExtra(Intent.EXTRA_TEXT);
224         if (iccId == null || phoneNumber == null || TextUtils.isEmpty(message)) {
225             L.e("Dropping voice reply. Received no icc id, phone Number and/or empty message!");
226             return;
227         }
228         L.d("Sending a message to specified phone number");
229         AppFactory.get()
230                 .getDataModel()
231                 .sendMessage(iccId, phoneNumber.toString(), message.toString());
232     }
233 
234     /** Sends a reply, meant to be used from a caller originating from voice input. */
voiceReply(Intent intent)235     public static void voiceReply(Intent intent) {
236         final String conversationKey = intent.getStringExtra(EXTRA_CONVERSATION_KEY);
237         final int accountId =
238                 intent.getIntExtra(MessageConstants.EXTRA_ACCOUNT_ID, NULL_ACCOUNT_ID);
239         final Bundle bundle = RemoteInput.getResultsFromIntent(intent);
240         if (bundle == null || accountId == NULL_ACCOUNT_ID) {
241             L.e("Dropping voice reply. Received null bundle or no user account id in bundle!");
242             return;
243         }
244         final CharSequence message = bundle.getCharSequence(Intent.EXTRA_TEXT);
245         L.d("voiceReply: " + message);
246         if (!TextUtils.isEmpty(message)) {
247             AppFactory.get()
248                     .getDataModel()
249                     .replyConversation(accountId, conversationKey, message.toString());
250         }
251     }
252 
253     /** Mark a conversation associated with a given sender key as read. */
mute(Intent intent)254     public static void mute(Intent intent) {
255         Bundle extras = intent.getExtras();
256         if (extras != null) {
257             final String conversationKey = extras.getString(EXTRA_CONVERSATION_KEY);
258             L.d("mute");
259             AppFactory.get().getDataModel().muteConversation(conversationKey, true);
260         }
261     }
262 
263     /** Mark a conversation associated with a given sender key as read. */
markAsRead(Intent intent)264     public static void markAsRead(Intent intent) {
265         Bundle extras = intent.getExtras();
266         if (extras != null) {
267             final String conversationKey = extras.getString(EXTRA_CONVERSATION_KEY);
268             L.d("marking as read");
269             AppFactory.get().getDataModel().markAsRead(conversationKey);
270         }
271     }
272 }
273