| package com.android.car.messenger; |
| |
| |
| import android.app.Notification; |
| import android.app.NotificationManager; |
| import android.app.PendingIntent; |
| import android.bluetooth.BluetoothAdapter; |
| import android.bluetooth.BluetoothDevice; |
| import android.bluetooth.BluetoothMapClient; |
| import android.bluetooth.BluetoothProfile; |
| import android.content.ContentResolver; |
| import android.content.ContentUris; |
| import android.content.Context; |
| import android.content.Intent; |
| import android.content.res.Resources.NotFoundException; |
| import android.database.Cursor; |
| import android.graphics.Bitmap; |
| import android.graphics.drawable.Drawable; |
| import android.net.Uri; |
| import android.provider.ContactsContract; |
| import android.text.TextUtils; |
| import android.widget.Toast; |
| |
| import androidx.annotation.Nullable; |
| import androidx.annotation.VisibleForTesting; |
| import androidx.core.app.NotificationCompat; |
| import androidx.core.app.NotificationCompat.Action; |
| import androidx.core.app.NotificationCompat.MessagingStyle; |
| import androidx.core.app.Person; |
| import androidx.core.app.RemoteInput; |
| |
| import com.android.car.apps.common.LetterTileDrawable; |
| import com.android.car.messenger.bluetooth.BluetoothHelper; |
| import com.android.car.messenger.bluetooth.BluetoothMonitor; |
| import com.android.car.messenger.log.L; |
| import com.android.internal.annotations.GuardedBy; |
| |
| import com.bumptech.glide.Glide; |
| import com.bumptech.glide.request.RequestOptions; |
| import com.bumptech.glide.request.target.SimpleTarget; |
| import com.bumptech.glide.request.transition.Transition; |
| |
| import java.util.ArrayList; |
| import java.util.HashMap; |
| import java.util.LinkedList; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.function.Predicate; |
| |
| /** Delegate class responsible for handling messaging service actions */ |
| public class MessengerDelegate implements BluetoothMonitor.OnBluetoothEventListener { |
| private static final String TAG = "CM.MessengerDelegate"; |
| private static final Object mMapClientLock = new Object(); |
| |
| private final Context mContext; |
| @GuardedBy("mMapClientLock") |
| private BluetoothMapClient mBluetoothMapClient; |
| private NotificationManager mNotificationManager; |
| private final SmsDatabaseHandler mSmsDatabaseHandler; |
| private boolean mShouldLoadExistingMessages; |
| |
| @VisibleForTesting |
| final Map<MessageKey, MapMessage> mMessages = new HashMap<>(); |
| @VisibleForTesting |
| final Map<SenderKey, NotificationInfo> mNotificationInfos = new HashMap<>(); |
| // Mapping of when a device was connected via BluetoothMapClient. Used so we don't show |
| // Notifications for messages received before this time. |
| @VisibleForTesting |
| final Map<String, Long> mBTDeviceAddressToConnectionTimestamp = new HashMap<>(); |
| |
| public MessengerDelegate(Context context) { |
| mContext = context; |
| |
| mNotificationManager = |
| (NotificationManager) mContext.getSystemService(Context.NOTIFICATION_SERVICE); |
| mSmsDatabaseHandler = new SmsDatabaseHandler(mContext); |
| |
| try { |
| mShouldLoadExistingMessages = |
| mContext.getResources().getBoolean(R.bool.config_loadExistingMessages); |
| } catch(NotFoundException e) { |
| // Should only happen for robolectric unit tests; |
| L.e(TAG, e, "Disabling loading of existing messages"); |
| mShouldLoadExistingMessages = false; |
| } |
| } |
| |
| @Override |
| public void onMessageReceived(Intent intent) { |
| try { |
| MapMessage message = MapMessage.parseFrom(intent); |
| |
| MessageKey messageKey = new MessageKey(message); |
| boolean repeatMessage = mMessages.containsKey(messageKey); |
| mMessages.put(messageKey, message); |
| if (!repeatMessage) { |
| mSmsDatabaseHandler.addOrUpdate(message); |
| updateNotification(messageKey, message); |
| } |
| } catch (IllegalArgumentException e) { |
| L.e(TAG, e, "Dropping invalid MAP message."); |
| } |
| } |
| |
| @Override |
| public void onMessageSent(Intent intent) { |
| /* NO-OP */ |
| } |
| |
| @Override |
| public void onDeviceConnected(BluetoothDevice device) { |
| L.d(TAG, "Device connected: \t%s", device.getAddress()); |
| mBTDeviceAddressToConnectionTimestamp.put(device.getAddress(), System.currentTimeMillis()); |
| synchronized (mMapClientLock) { |
| if (mBluetoothMapClient != null) { |
| if (mShouldLoadExistingMessages) { |
| mBluetoothMapClient.getUnreadMessages(device); |
| } |
| } else { |
| // onDeviceConnected should be sent by BluetoothMapClient, so log if we run into |
| // this strange case. |
| L.e(TAG, "BluetoothMapClient is null after connecting to device."); |
| } |
| } |
| } |
| |
| @Override |
| public void onDeviceDisconnected(BluetoothDevice device) { |
| L.d(TAG, "Device disconnected: \t%s", device.getAddress()); |
| cleanupMessagesAndNotifications(key -> key.matches(device.getAddress())); |
| mBTDeviceAddressToConnectionTimestamp.remove(device.getAddress()); |
| mSmsDatabaseHandler.removeMessagesForDevice(device.getAddress()); |
| } |
| |
| @Override |
| public void onMapConnected(BluetoothMapClient client) { |
| List<BluetoothDevice> connectedDevices; |
| synchronized (mMapClientLock) { |
| if (mBluetoothMapClient == client) { |
| return; |
| } |
| |
| if (mBluetoothMapClient != null) { |
| mBluetoothMapClient.close(); |
| } |
| |
| mBluetoothMapClient = client; |
| connectedDevices = mBluetoothMapClient.getConnectedDevices(); |
| } |
| if (connectedDevices != null) { |
| for (BluetoothDevice device : connectedDevices) { |
| onDeviceConnected(device); |
| } |
| } |
| } |
| |
| @Override |
| public void onMapDisconnected(int profile) { |
| cleanupMessagesAndNotifications(key -> true); |
| synchronized (mMapClientLock) { |
| BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter(); |
| if (adapter != null) { |
| adapter.closeProfileProxy(BluetoothProfile.MAP_CLIENT, mBluetoothMapClient); |
| } |
| mBluetoothMapClient = null; |
| } |
| } |
| |
| @Override |
| public void onSdpRecord(BluetoothDevice device, boolean supportsReply) { |
| /* NO_OP */ |
| } |
| |
| protected void sendMessage(SenderKey senderKey, String messageText) { |
| boolean success = false; |
| // Even if the device is not connected, try anyway so that the reply in enqueued. |
| synchronized (mMapClientLock) { |
| if (mBluetoothMapClient != null) { |
| NotificationInfo notificationInfo = mNotificationInfos.get(senderKey); |
| if (notificationInfo == null) { |
| L.w(TAG, "No notificationInfo found for senderKey: %s", senderKey); |
| } else if (notificationInfo.mSenderContactUri == null) { |
| L.w(TAG, "Do not have contact URI for sender!"); |
| } else { |
| Uri[] recipientUris = {Uri.parse(notificationInfo.mSenderContactUri)}; |
| |
| final int requestCode = senderKey.hashCode(); |
| |
| Intent intent = new Intent(BluetoothMapClient.ACTION_MESSAGE_SENT_SUCCESSFULLY); |
| PendingIntent sentIntent = PendingIntent.getBroadcast(mContext, requestCode, |
| intent, |
| PendingIntent.FLAG_ONE_SHOT); |
| |
| success = BluetoothHelper.sendMessage(mBluetoothMapClient, |
| senderKey.getDeviceAddress(), recipientUris, messageText, |
| sentIntent, null); |
| } |
| } |
| } |
| |
| final boolean deviceConnected = mBTDeviceAddressToConnectionTimestamp.containsKey( |
| senderKey.getDeviceAddress()); |
| if (!success || !deviceConnected) { |
| L.e(TAG, "Unable to send reply!"); |
| final int toastResource = deviceConnected |
| ? R.string.auto_reply_failed_message |
| : R.string.auto_reply_device_disconnected; |
| |
| Toast.makeText(mContext, toastResource, Toast.LENGTH_SHORT).show(); |
| } |
| } |
| |
| protected void markAsRead(SenderKey senderKey) { |
| NotificationInfo info = mNotificationInfos.get(senderKey); |
| for (MessageKey key : info.mMessageKeys) { |
| MapMessage message = mMessages.get(key); |
| if (!message.isReadOnCar()) { |
| message.markMessageAsRead(); |
| mSmsDatabaseHandler.addOrUpdate(message); |
| } |
| } |
| } |
| |
| /** |
| * Clears all notifications matching the {@param predicate}. Example method calls are when user |
| * wants to clear (a) message notification(s), or when the Bluetooth device that received the |
| * messages has been disconnected. |
| */ |
| protected void clearNotifications(Predicate<CompositeKey> predicate) { |
| mNotificationInfos.forEach((senderKey, notificationInfo) -> { |
| if (predicate.test(senderKey)) { |
| mNotificationManager.cancel(notificationInfo.mNotificationId); |
| } |
| }); |
| } |
| |
| /** Removes all messages related to the inputted predicate, and cancels their notifications. **/ |
| private void cleanupMessagesAndNotifications(Predicate<CompositeKey> predicate) { |
| for (MessageKey key : mMessages.keySet()) { |
| if (predicate.test(key)) { |
| mSmsDatabaseHandler.removeMessagesForDevice(key.getDeviceAddress()); |
| } |
| } |
| mMessages.entrySet().removeIf( |
| messageKeyMapMessageEntry -> predicate.test(messageKeyMapMessageEntry.getKey())); |
| clearNotifications(predicate); |
| mNotificationInfos.entrySet().removeIf(entry -> predicate.test(entry.getKey())); |
| } |
| |
| private void updateNotification(MessageKey messageKey, MapMessage mapMessage) { |
| // Only show notifications for messages received AFTER phone was connected. |
| if (mapMessage.getReceiveTime() |
| < mBTDeviceAddressToConnectionTimestamp.get(mapMessage.getDeviceAddress())) { |
| return; |
| } |
| |
| SmsDatabaseHandler.readDatabase(mContext); |
| SenderKey senderKey = new SenderKey(mapMessage); |
| if (!mNotificationInfos.containsKey(senderKey)) { |
| mNotificationInfos.put(senderKey, new NotificationInfo(mapMessage.getSenderName(), |
| mapMessage.getSenderContactUri())); |
| } |
| NotificationInfo notificationInfo = mNotificationInfos.get(senderKey); |
| notificationInfo.mMessageKeys.add(messageKey); |
| |
| updateNotification(senderKey, notificationInfo); |
| } |
| |
| private void updateNotification(SenderKey senderKey, NotificationInfo notificationInfo) { |
| final Uri photoUri = ContentUris.withAppendedId(ContactsContract.Contacts.CONTENT_URI, |
| getContactId(mContext.getContentResolver(), notificationInfo.mSenderContactUri)); |
| |
| Glide.with(mContext) |
| .asBitmap() |
| .load(photoUri) |
| .apply(RequestOptions.circleCropTransform()) |
| .into(new SimpleTarget<Bitmap>() { |
| @Override |
| public void onResourceReady(Bitmap bitmap, |
| Transition<? super Bitmap> transition) { |
| sendNotification(bitmap); |
| } |
| |
| @Override |
| public void onLoadFailed(@Nullable Drawable fallback) { |
| sendNotification(null); |
| } |
| |
| private void sendNotification(Bitmap bitmap) { |
| mNotificationManager.notify( |
| notificationInfo.mNotificationId, |
| createNotification(senderKey, notificationInfo, bitmap)); |
| } |
| }); |
| } |
| |
| // TODO: move out to a shared library. |
| protected static int getContactId(ContentResolver cr, String contactUri) { |
| if (TextUtils.isEmpty(contactUri)) { |
| return 0; |
| } |
| |
| Uri lookupUri = Uri.withAppendedPath(ContactsContract.PhoneLookup.CONTENT_FILTER_URI, |
| Uri.encode(contactUri)); |
| String[] projection = new String[]{ContactsContract.PhoneLookup._ID}; |
| |
| try (Cursor cursor = cr.query(lookupUri, projection, null, null, null)) { |
| if (cursor != null && cursor.moveToFirst() && cursor.isLast()) { |
| return cursor.getInt(cursor.getColumnIndex(ContactsContract.PhoneLookup._ID)); |
| } else { |
| L.w(TAG, "Unable to find contact id from phone number."); |
| } |
| } |
| |
| return 0; |
| } |
| |
| protected void cleanup() { |
| cleanupMessagesAndNotifications(key -> true); |
| synchronized (mMapClientLock) { |
| if (mBluetoothMapClient != null) { |
| mBluetoothMapClient.close(); |
| } |
| } |
| } |
| |
| private Notification createNotification( |
| SenderKey senderKey, NotificationInfo notificationInfo, Bitmap bitmap) { |
| String contentText = mContext.getResources().getQuantityString( |
| R.plurals.notification_new_message, notificationInfo.mMessageKeys.size(), |
| notificationInfo.mMessageKeys.size()); |
| long lastReceiveTime = mMessages.get(notificationInfo.mMessageKeys.getLast()) |
| .getReceiveTime(); |
| |
| if (bitmap == null) { |
| bitmap = letterTileBitmap(notificationInfo.mSenderName); |
| } |
| |
| final String senderName = notificationInfo.mSenderName; |
| final int notificationId = notificationInfo.mNotificationId; |
| |
| // Create the Content Intent |
| PendingIntent deleteIntent = createServiceIntent(senderKey, notificationId, |
| MessengerService.ACTION_CLEAR_NOTIFICATION_STATE); |
| |
| List<Action> actions = getNotificationActions(senderKey, notificationId); |
| |
| Person user = new Person.Builder() |
| .setName(mContext.getString(R.string.name_not_available)) |
| .build(); |
| MessagingStyle messagingStyle = new MessagingStyle(user); |
| Person sender = new Person.Builder() |
| .setName(senderName) |
| .setUri(notificationInfo.mSenderContactUri) |
| .build(); |
| notificationInfo.mMessageKeys.stream().map(mMessages::get).forEachOrdered(message -> { |
| if (!message.isReadOnCar()) { |
| messagingStyle.addMessage( |
| message.getMessageText(), |
| message.getReceiveTime(), |
| sender); |
| } |
| }); |
| |
| NotificationCompat.Builder builder = new NotificationCompat.Builder(mContext, |
| MessengerService.SMS_CHANNEL_ID) |
| .setContentTitle(senderName) |
| .setContentText(contentText) |
| .setStyle(messagingStyle) |
| .setCategory(Notification.CATEGORY_MESSAGE) |
| .setLargeIcon(bitmap) |
| .setSmallIcon(R.drawable.ic_message) |
| .setWhen(lastReceiveTime) |
| .setShowWhen(true) |
| .setDeleteIntent(deleteIntent); |
| |
| for (final Action action : actions) { |
| builder.addAction(action); |
| } |
| |
| return builder.build(); |
| } |
| |
| private Bitmap letterTileBitmap(String senderName) { |
| LetterTileDrawable letterTileDrawable = new LetterTileDrawable(mContext.getResources()); |
| letterTileDrawable.setContactDetails(senderName, senderName); |
| letterTileDrawable.setIsCircular(true); |
| |
| int bitmapSize = mContext.getResources() |
| .getDimensionPixelSize(R.dimen.notification_contact_photo_size); |
| |
| return letterTileDrawable.toBitmap(bitmapSize); |
| } |
| |
| private PendingIntent createServiceIntent(SenderKey senderKey, int notificationId, |
| String action) { |
| Intent intent = new Intent(mContext, MessengerService.class) |
| .setAction(action) |
| .putExtra(MessengerService.EXTRA_SENDER_KEY, senderKey); |
| |
| return PendingIntent.getForegroundService(mContext, notificationId, intent, |
| PendingIntent.FLAG_UPDATE_CURRENT); |
| } |
| |
| private List<Action> getNotificationActions(SenderKey senderKey, int notificationId) { |
| |
| final int icon = android.R.drawable.ic_media_play; |
| |
| final List<Action> actionList = new ArrayList<>(); |
| |
| // Reply action |
| if (shouldAddReplyAction(senderKey.getDeviceAddress())) { |
| final String replyString = mContext.getString(R.string.action_reply); |
| PendingIntent replyIntent = createServiceIntent(senderKey, notificationId, |
| MessengerService.ACTION_VOICE_REPLY); |
| actionList.add( |
| new Action.Builder(icon, replyString, replyIntent) |
| .setSemanticAction(Action.SEMANTIC_ACTION_REPLY) |
| .setShowsUserInterface(false) |
| .addRemoteInput( |
| new RemoteInput.Builder(MessengerService.REMOTE_INPUT_KEY) |
| .build() |
| ) |
| .build() |
| ); |
| } |
| |
| // Mark-as-read Action. This will be the callback of Notification Center's "Read" action. |
| final String markAsRead = mContext.getString(R.string.action_mark_as_read); |
| PendingIntent markAsReadIntent = createServiceIntent(senderKey, notificationId, |
| MessengerService.ACTION_MARK_AS_READ); |
| actionList.add( |
| new Action.Builder(icon, markAsRead, markAsReadIntent) |
| .setSemanticAction(Action.SEMANTIC_ACTION_MARK_AS_READ) |
| .setShowsUserInterface(false) |
| .build() |
| ); |
| |
| return actionList; |
| } |
| |
| private boolean shouldAddReplyAction(String deviceAddress) { |
| BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter(); |
| if (adapter == null) { |
| return false; |
| } |
| BluetoothDevice device = adapter.getRemoteDevice(deviceAddress); |
| |
| synchronized (mMapClientLock) { |
| return (mBluetoothMapClient != null) && mBluetoothMapClient.isUploadingSupported( |
| device); |
| } |
| } |
| |
| /** |
| * Contains information about a single notification that is displayed, with grouped messages. |
| */ |
| @VisibleForTesting |
| static class NotificationInfo { |
| private static int NEXT_NOTIFICATION_ID = 0; |
| |
| final int mNotificationId = NEXT_NOTIFICATION_ID++; |
| final String mSenderName; |
| @Nullable |
| final String mSenderContactUri; |
| final LinkedList<MessageKey> mMessageKeys = new LinkedList<>(); |
| |
| NotificationInfo(String senderName, @Nullable String senderContactUri) { |
| mSenderName = senderName; |
| mSenderContactUri = senderContactUri; |
| } |
| } |
| |
| /** |
| * {@link CompositeKey} subclass used to identify specific messages; it uses message-handle as |
| * the secondary key. |
| */ |
| public static class MessageKey extends CompositeKey { |
| MessageKey(MapMessage message) { |
| super(message.getDeviceAddress(), message.getHandle()); |
| } |
| } |
| } |