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 
17 package com.android.car.messenger;
18 
19 
20 import static com.android.car.apps.common.util.SafeLog.logd;
21 import static com.android.car.apps.common.util.SafeLog.loge;
22 import static com.android.car.apps.common.util.SafeLog.logw;
23 
24 import android.annotation.Nullable;
25 import android.app.PendingIntent;
26 import android.bluetooth.BluetoothAdapter;
27 import android.bluetooth.BluetoothDevice;
28 import android.bluetooth.BluetoothMapClient;
29 import android.content.Context;
30 import android.content.Intent;
31 import android.content.res.Resources;
32 import android.graphics.Bitmap;
33 import android.graphics.drawable.Drawable;
34 import android.graphics.drawable.Icon;
35 import android.net.Uri;
36 import android.widget.Toast;
37 
38 import androidx.core.graphics.drawable.RoundedBitmapDrawable;
39 import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory;
40 
41 import com.android.car.messenger.bluetooth.BluetoothHelper;
42 import com.android.car.messenger.bluetooth.BluetoothMonitor;
43 import com.android.car.messenger.common.BaseNotificationDelegate;
44 import com.android.car.messenger.common.ConversationKey;
45 import com.android.car.messenger.common.ConversationNotificationInfo;
46 import com.android.car.messenger.common.Message;
47 import com.android.car.messenger.common.ProjectionStateListener;
48 import com.android.car.messenger.common.SenderKey;
49 import com.android.car.messenger.common.Utils;
50 import com.android.car.telephony.common.TelecomUtils;
51 import com.android.internal.annotations.GuardedBy;
52 
53 import com.bumptech.glide.Glide;
54 import com.bumptech.glide.request.RequestOptions;
55 import com.bumptech.glide.request.target.SimpleTarget;
56 import com.bumptech.glide.request.transition.Transition;
57 
58 import java.util.ArrayList;
59 import java.util.Collections;
60 import java.util.HashMap;
61 import java.util.HashSet;
62 import java.util.List;
63 import java.util.Map;
64 import java.util.Set;
65 import java.util.concurrent.CompletableFuture;
66 import java.util.function.Consumer;
67 import java.util.stream.Collectors;
68 
69 /** Delegate class responsible for handling messaging service actions */
70 public class MessageNotificationDelegate extends BaseNotificationDelegate implements
71         BluetoothMonitor.OnBluetoothEventListener {
72     private static final String TAG = "MsgNotiDelegate";
73     private static final Object mMapClientLock = new Object();
74 
75     @GuardedBy("mMapClientLock")
76     private BluetoothMapClient mBluetoothMapClient;
77     /** Tracks whether a projection application is active in the foreground. **/
78     private ProjectionStateListener mProjectionStateListener;
79     private CompletableFuture<Void> mPhoneNumberInfoFuture;
80     private static int mBitmapSize;
81     private static float mCornerRadiusPercent;
82     private static boolean mShouldLoadExistingMessages;
83     private static int mNotificationConversationTitleLength;
84 
85     final Map<String, Long> mBtDeviceAddressToConnectionTimestamp = new HashMap<>();
86     final Map<SenderKey, Bitmap> mSenderToLargeIconBitmap = new HashMap<>();
87     final Map<String, String> mUriToSenderNameMap = new HashMap<>();
88     final Set<ConversationKey> mGeneratedGroupConversationTitles = new HashSet<>();
89 
MessageNotificationDelegate(Context context)90     public MessageNotificationDelegate(Context context) {
91         super(context, /* useLetterTile */ true);
92         mProjectionStateListener = new ProjectionStateListener(context);
93         loadConfigValues(context);
94     }
95 
96     /** Loads all necessary values from the config.xml at creation or when values are changed. **/
loadConfigValues(Context context)97     protected static void loadConfigValues(Context context) {
98         mBitmapSize = 300;
99         mCornerRadiusPercent = (float) 0.5;
100         mShouldLoadExistingMessages = false;
101         mNotificationConversationTitleLength = 30;
102         try {
103             mBitmapSize =
104                     context.getResources()
105                             .getDimensionPixelSize(R.dimen.notification_contact_photo_size);
106             mCornerRadiusPercent = context.getResources()
107                     .getFloat(R.dimen.contact_avatar_corner_radius_percent);
108             mShouldLoadExistingMessages =
109                     context.getResources().getBoolean(R.bool.config_loadExistingMessages);
110             mNotificationConversationTitleLength = context.getResources().getInteger(
111                     R.integer.notification_conversation_title_length);
112         } catch (Resources.NotFoundException e) {
113             // Should only happen for robolectric unit tests;
114             loge(TAG, "Disabling loading of existing messages: " + e.getMessage());
115         }
116     }
117 
118     @Override
onMessageReceived(Intent intent)119     public void onMessageReceived(Intent intent) {
120         addNamesToSenderMap(intent);
121         if (Utils.isGroupConversation(intent)) {
122             // Group Conversations have URIs of senders whose names we need to load from the DB.
123             loadNamesFromDatabase(intent);
124         }
125         loadAvatarIconAndProcessMessage(intent);
126     }
127 
128     @Override
onMessageSent(Intent intent)129     public void onMessageSent(Intent intent) {
130         logd(TAG, "onMessageSent");
131     }
132 
133     @Override
onDeviceConnected(BluetoothDevice device)134     public void onDeviceConnected(BluetoothDevice device) {
135         logd(TAG, "Device connected: " + device.getAddress());
136         mBtDeviceAddressToConnectionTimestamp.put(device.getAddress(), System.currentTimeMillis());
137         synchronized (mMapClientLock) {
138             if (mBluetoothMapClient != null) {
139                 if (mShouldLoadExistingMessages) {
140                     mBluetoothMapClient.getUnreadMessages(device);
141                 }
142             } else {
143                 // onDeviceConnected should be sent by BluetoothMapClient, so log if we run into
144                 // this strange case.
145                 loge(TAG, "BluetoothMapClient is null after connecting to device.");
146             }
147         }
148     }
149 
150     @Override
onDeviceDisconnected(BluetoothDevice device)151     public void onDeviceDisconnected(BluetoothDevice device) {
152         logd(TAG, "Device disconnected: " + device.getAddress());
153         cleanupMessagesAndNotifications(key -> key.matches(device.getAddress()));
154         mBtDeviceAddressToConnectionTimestamp.remove(device.getAddress());
155     }
156 
157     @Override
onMapConnected(BluetoothMapClient client)158     public void onMapConnected(BluetoothMapClient client) {
159         logd(TAG, "Connected to BluetoothMapClient");
160         List<BluetoothDevice> connectedDevices;
161         synchronized (mMapClientLock) {
162             if (mBluetoothMapClient == client) {
163                 return;
164             }
165 
166             mBluetoothMapClient = client;
167             connectedDevices = mBluetoothMapClient.getConnectedDevices();
168         }
169         if (connectedDevices != null) {
170             for (BluetoothDevice device : connectedDevices) {
171                 onDeviceConnected(device);
172             }
173         }
174     }
175 
176     @Override
onMapDisconnected()177     public void onMapDisconnected() {
178         logd(TAG, "Disconnected from BluetoothMapClient");
179         resetInternalData();
180         synchronized (mMapClientLock) {
181             mBluetoothMapClient = null;
182         }
183     }
184 
185     @Override
onSdpRecord(BluetoothDevice device, boolean supportsReply)186     public void onSdpRecord(BluetoothDevice device, boolean supportsReply) {
187         /* NO_OP */
188     }
189 
markAsRead(ConversationKey convoKey)190     protected void markAsRead(ConversationKey convoKey) {
191         excludeFromNotification(convoKey);
192     }
193 
dismiss(ConversationKey convoKey)194     protected void dismiss(ConversationKey convoKey) {
195         super.dismissInternal(convoKey);
196     }
197 
198     @Override
shouldAddReplyAction(String deviceAddress)199     protected boolean shouldAddReplyAction(String deviceAddress) {
200         BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter();
201         if (adapter == null) {
202             return false;
203         }
204         BluetoothDevice device = adapter.getRemoteDevice(deviceAddress);
205 
206         synchronized (mMapClientLock) {
207             return (mBluetoothMapClient != null) && mBluetoothMapClient.isUploadingSupported(
208                     device);
209         }
210     }
211 
sendMessage(ConversationKey conversationKey, String messageText)212     protected void sendMessage(ConversationKey conversationKey, String messageText) {
213         final boolean deviceConnected = mBtDeviceAddressToConnectionTimestamp.containsKey(
214                 conversationKey.getDeviceId());
215         if (!deviceConnected) {
216             logw(TAG, "sendMessage: device disconnected, can't send message");
217             return;
218         }
219         boolean success = false;
220         synchronized (mMapClientLock) {
221             if (mBluetoothMapClient != null) {
222                 ConversationNotificationInfo notificationInfo = mNotificationInfos.get(
223                         conversationKey);
224                 if (notificationInfo == null) {
225                     logw(TAG, "No notificationInfo found for senderKey "
226                             + conversationKey.toString());
227                 } else if (notificationInfo.getCcRecipientsUris().isEmpty()) {
228                     logw(TAG, "No contact URI for sender!");
229                 } else {
230                     success = sendMessageInternal(conversationKey, messageText);
231                 }
232             }
233         }
234 
235         if (!success) {
236             Toast.makeText(mContext, R.string.auto_reply_failed_message, Toast.LENGTH_SHORT).show();
237         }
238     }
239 
onDestroy()240     protected void onDestroy() {
241         resetInternalData();
242         if (mPhoneNumberInfoFuture != null) {
243             mPhoneNumberInfoFuture.cancel(true);
244         }
245         mProjectionStateListener.destroy();
246     }
247 
resetInternalData()248     private void resetInternalData() {
249         cleanupMessagesAndNotifications(key -> true);
250         mUriToSenderNameMap.clear();
251         mSenderToLargeIconBitmap.clear();
252         mBtDeviceAddressToConnectionTimestamp.clear();
253     }
254 
255     /**
256      * Creates a new message and links it to the conversation identified by the convoKey. Then
257      * posts the message notification after all loading queries from the database have finished.
258      */
initializeNewMessage(ConversationKey convoKey, Message message)259     private void initializeNewMessage(ConversationKey convoKey, Message message) {
260         addMessageToNotificationInfo(message, convoKey);
261         ConversationNotificationInfo notificationInfo = mNotificationInfos.get(convoKey);
262         // Only show notifications for messages received AFTER phone was connected.
263         mPhoneNumberInfoFuture.thenRun(() -> {
264             setGroupConversationTitle(convoKey);
265             // Only show notifications for messages received AFTER phone was connected.
266             if (message.getReceivedTime()
267                     >= mBtDeviceAddressToConnectionTimestamp.get(convoKey.getDeviceId())) {
268                 postNotification(convoKey, notificationInfo, getChannelId(convoKey.getDeviceId()),
269                         mSenderToLargeIconBitmap.get(message.getSenderKey()));
270             }
271         });
272     }
273 
274     /**
275      * Creates a new conversation with all of the conversation metadata, and adds the first
276      * message to the conversation.
277      */
initializeNewConversation(ConversationKey convoKey, Intent intent)278     private void initializeNewConversation(ConversationKey convoKey, Intent intent) {
279         if (mNotificationInfos.containsKey(convoKey)) {
280             logw(TAG, "Conversation already exists! " + convoKey.toString());
281         }
282         Message message = Message.parseFromIntent(intent);
283         ConversationNotificationInfo notiInfo;
284         try {
285             // Pass in null icon, since the fallback icon represents the system app's icon.
286             notiInfo =
287                     ConversationNotificationInfo.createConversationNotificationInfo(intent,
288                             message.getSenderName(), mContext.getClass().getName(),
289                             /* appIcon */ null);
290         } catch (IllegalArgumentException e) {
291             logw(TAG, "initNewConvo: Message could not be created from the intent.");
292             return;
293         }
294         mNotificationInfos.put(convoKey, notiInfo);
295         initializeNewMessage(convoKey, message);
296     }
297 
298     /** Loads the avatar icon, and processes the message after avatar is loaded. **/
loadAvatarIconAndProcessMessage(Intent intent)299     private void loadAvatarIconAndProcessMessage(Intent intent) {
300         SenderKey senderKey = SenderKey.createSenderKey(intent);
301         String phoneNumber = Utils.getPhoneNumberFromMapClient(Utils.getSenderUri(intent));
302         if (mSenderToLargeIconBitmap.containsKey(senderKey) || phoneNumber == null) {
303             addMessageFromIntent(intent);
304             return;
305         }
306         loadPhoneNumberInfo(phoneNumber, phoneNumberInfo -> {
307             if (phoneNumberInfo == null) {
308                 return;
309             }
310             Glide.with(mContext)
311                     .asBitmap()
312                     .load(phoneNumberInfo.getAvatarUri())
313                     .apply(new RequestOptions().override(mBitmapSize))
314                     .into(new SimpleTarget<Bitmap>() {
315                         @Override
316                         public void onResourceReady(Bitmap bitmap,
317                                 Transition<? super Bitmap> transition) {
318                             RoundedBitmapDrawable roundedBitmapDrawable =
319                                     RoundedBitmapDrawableFactory
320                                             .create(mContext.getResources(), bitmap);
321                             Icon avatarIcon = TelecomUtils
322                                     .createFromRoundedBitmapDrawable(roundedBitmapDrawable,
323                                             mBitmapSize,
324                                             mCornerRadiusPercent);
325                             mSenderToLargeIconBitmap.put(senderKey, avatarIcon.getBitmap());
326                             addMessageFromIntent(intent);
327                             return;
328                         }
329 
330                         @Override
331                         public void onLoadFailed(@Nullable Drawable fallback) {
332                             addMessageFromIntent(intent);
333                             return;
334                         }
335                     });
336         });
337     }
338 
339     /**
340      * Extracts the message from the intent and creates a new conversation or message
341      * appropriately.
342      */
addMessageFromIntent(Intent intent)343     private void addMessageFromIntent(Intent intent) {
344         ConversationKey convoKey = ConversationKey.createConversationKey(intent);
345 
346         if (convoKey == null) return;
347         logd(TAG, "Received message from " + convoKey.getDeviceId());
348         if (mNotificationInfos.containsKey(convoKey)) {
349             try {
350                 initializeNewMessage(convoKey, Message.parseFromIntent(intent));
351             } catch (IllegalArgumentException e) {
352                 logw(TAG, "addMessage: Message could not be created from the intent.");
353                 return;
354             }
355         } else {
356             initializeNewConversation(convoKey, intent);
357         }
358     }
359 
addNamesToSenderMap(Intent intent)360     private void addNamesToSenderMap(Intent intent) {
361         String senderUri = Utils.getSenderUri(intent);
362         String senderName = Utils.getSenderName(intent);
363         if (senderUri != null) {
364             mUriToSenderNameMap.put(senderUri, senderName);
365         }
366     }
367 
368     /**
369      * Loads the name of a sender based on the sender's contact URI.
370      *
371      * This is needed to load the participants' names of a group conversation since
372      * {@link BluetoothMapClient} only sends the URIs of these participants.
373      */
loadNamesFromDatabase(Intent intent)374     private void loadNamesFromDatabase(Intent intent) {
375         for (String uri : Utils.getInclusiveRecipientsUrisList(intent)) {
376             String phoneNumber = Utils.getPhoneNumberFromMapClient(uri);
377             if (phoneNumber != null && !mUriToSenderNameMap.containsKey(uri)) {
378                 loadPhoneNumberInfo(phoneNumber, (phoneNumberInfo) -> {
379                     mUriToSenderNameMap.put(uri, phoneNumberInfo.getDisplayName());
380                 });
381             }
382         }
383     }
384 
385     /**
386      * Sets the group conversation title using the names of all the participants in the group.
387      * If all the participants' names have been loaded from the database, then we don't need
388      * to generate the title again.
389      *
390      * A group conversation's title should be an alphabetically sorted list of the participant's
391      * names, separated by commas.
392      */
setGroupConversationTitle(ConversationKey conversationKey)393     private void setGroupConversationTitle(ConversationKey conversationKey) {
394         ConversationNotificationInfo notificationInfo = mNotificationInfos.get(conversationKey);
395         if (!notificationInfo.isGroupConvo()
396                 || mGeneratedGroupConversationTitles.contains(conversationKey)) {
397             return;
398         }
399 
400         List<String> names = new ArrayList<>();
401 
402         boolean allNamesLoaded = true;
403         for (String uri : notificationInfo.getCcRecipientsUris()) {
404             if (mUriToSenderNameMap.containsKey(uri)) {
405                 names.add(mUriToSenderNameMap.get(uri));
406             } else {
407                 names.add(Utils.getPhoneNumberFromMapClient(uri));
408                 // This URI has not been loaded from the database, set allNamesLoaded to false.
409                 allNamesLoaded = false;
410             }
411         }
412 
413         notificationInfo.setConvoTitle(constructGroupConversationTitle(names));
414         if (allNamesLoaded) mGeneratedGroupConversationTitles.add(conversationKey);
415     }
416 
417     /**
418      * Given a name of all the participants in a group conversation (some names might be phone
419      * numbers), this function creates the conversation title putting the names in alphabetical
420      * order first, then adding any phone numbers. This title should not exceed the
421      * mNotificationConversationTitleLength, so not all participants' names are guaranteed to be
422      * in the conversation title.
423      */
constructGroupConversationTitle(List<String> names)424     private String constructGroupConversationTitle(List<String> names) {
425         Collections.sort(names, Utils.ALPHA_THEN_NUMERIC_COMPARATOR);
426 
427         return names.stream().map(String::valueOf).collect(
428                 Collectors.joining(mContext.getString(R.string.name_separator)));
429     }
430 
loadPhoneNumberInfo(@ullable String phoneNumber, Consumer<? super TelecomUtils.PhoneNumberInfo> action)431     private void loadPhoneNumberInfo(@Nullable String phoneNumber,
432             Consumer<? super TelecomUtils.PhoneNumberInfo> action) {
433         if (phoneNumber == null) {
434             logw(TAG, " Could not load PhoneNumberInfo due to null phone number");
435             return;
436         }
437 
438         mPhoneNumberInfoFuture = TelecomUtils.getPhoneNumberInfo(mContext, phoneNumber)
439                 .thenAcceptAsync(action, mContext.getMainExecutor());
440     }
441 
getChannelId(String deviceAddress)442     private String getChannelId(String deviceAddress) {
443         if (mProjectionStateListener.isProjectionInActiveForeground(deviceAddress)) {
444             return MessengerService.SILENT_SMS_CHANNEL_ID;
445         }
446         return MessengerService.SMS_CHANNEL_ID;
447     }
448 
449     /** Sends reply message to the BluetoothMapClient to send to the connected phone. **/
sendMessageInternal(ConversationKey conversationKey, String messageText)450     private boolean sendMessageInternal(ConversationKey conversationKey, String messageText) {
451         ConversationNotificationInfo notificationInfo = mNotificationInfos.get(conversationKey);
452         Uri[] recipientUrisArray = generateRecipientUriArray(notificationInfo);
453 
454         final int requestCode = conversationKey.hashCode();
455 
456         Intent intent = new Intent(BluetoothMapClient.ACTION_MESSAGE_SENT_SUCCESSFULLY);
457         PendingIntent sentIntent = PendingIntent.getBroadcast(mContext, requestCode,
458                 intent,
459                 PendingIntent.FLAG_ONE_SHOT);
460 
461         try {
462             return BluetoothHelper.sendMessage(mBluetoothMapClient,
463                     conversationKey.getDeviceId(), recipientUrisArray, messageText,
464                     sentIntent, null);
465         } catch (IllegalArgumentException e) {
466             logw(TAG, "Invalid device address: " + conversationKey.getDeviceId());
467         }
468         return false;
469     }
470 
471     /**
472      * Generate an array containing all the recipients' URIs that should receive the user's
473      * message for the given notificationInfo.
474      */
generateRecipientUriArray(ConversationNotificationInfo notificationInfo)475     private Uri[] generateRecipientUriArray(ConversationNotificationInfo notificationInfo) {
476         List<String> ccRecipientsUris = notificationInfo.getCcRecipientsUris();
477         Uri[] recipientUris = new Uri[ccRecipientsUris.size()];
478 
479         for (int i = 0; i < ccRecipientsUris.size(); i++) {
480             recipientUris[i] = Uri.parse(ccRecipientsUris.get(i));
481         }
482         return recipientUris;
483     }
484 }
485