Home | History | Annotate | Download | only in messenger
      1 /*
      2  * Copyright (C) 2017 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 import android.app.Notification;
     20 import android.app.NotificationChannel;
     21 import android.app.NotificationManager;
     22 import android.app.PendingIntent;
     23 import android.bluetooth.BluetoothAdapter;
     24 import android.bluetooth.BluetoothDevice;
     25 import android.bluetooth.BluetoothMapClient;
     26 import android.bluetooth.BluetoothUuid;
     27 import android.bluetooth.SdpMasRecord;
     28 import android.content.BroadcastReceiver;
     29 import android.content.ContentResolver;
     30 import android.content.ContentUris;
     31 import android.content.Context;
     32 import android.content.Intent;
     33 import android.content.IntentFilter;
     34 import android.database.Cursor;
     35 import android.graphics.Bitmap;
     36 import android.graphics.drawable.Drawable;
     37 import android.graphics.drawable.Icon;
     38 import android.net.Uri;
     39 import android.os.Parcel;
     40 import android.os.Parcelable;
     41 import android.provider.ContactsContract;
     42 import android.provider.Settings;
     43 import android.text.TextUtils;
     44 import android.util.Log;
     45 import android.widget.Toast;
     46 
     47 import androidx.annotation.Nullable;
     48 
     49 import com.android.car.apps.common.LetterTileDrawable;
     50 import com.android.car.messenger.tts.TTSHelper;
     51 import com.bumptech.glide.Glide;
     52 import com.bumptech.glide.request.RequestOptions;
     53 import com.bumptech.glide.request.target.SimpleTarget;
     54 import com.bumptech.glide.request.transition.Transition;
     55 
     56 import java.util.ArrayList;
     57 import java.util.HashMap;
     58 import java.util.Iterator;
     59 import java.util.LinkedList;
     60 import java.util.List;
     61 import java.util.Map;
     62 import java.util.Objects;
     63 import java.util.function.Predicate;
     64 import java.util.stream.Collectors;
     65 
     66 /**
     67  * Monitors for incoming messages and posts/updates notifications.
     68  * <p>
     69  * It also handles notifications requests e.g. sending auto-replies and message play-out.
     70  * <p>
     71  * It will receive broadcasts for new incoming messages as long as the MapClient is connected in
     72  * {@link MessengerService}.
     73  */
     74 class MapMessageMonitor {
     75     public static final String ACTION_MESSAGE_PLAY_START =
     76             "car.messenger.action_message_play_start";
     77     public static final String ACTION_MESSAGE_PLAY_STOP = "car.messenger.action_message_play_stop";
     78     // reply or "upload" feature is indicated by the 3rd bit
     79     private static final int REPLY_FEATURE_POS = 3;
     80 
     81     private static final int REQUEST_CODE_VOICE_PLATE = 1;
     82     private static final int REQUEST_CODE_AUTO_REPLY = 2;
     83     private static final int ACTION_COUNT = 2;
     84     private static final String TAG = "Messenger.MsgMonitor";
     85     private static final boolean DBG = MessengerService.DBG;
     86 
     87     private final Context mContext;
     88     private final BluetoothMapReceiver mBluetoothMapReceiver;
     89     private final BluetoothSdpReceiver mBluetoothSdpReceiver;
     90     private final NotificationManager mNotificationManager;
     91     private final Map<MessageKey, MapMessage> mMessages = new HashMap<>();
     92     private final Map<SenderKey, NotificationInfo> mNotificationInfos = new HashMap<>();
     93     private final TTSHelper mTTSHelper;
     94     private final HashMap<String, Boolean> mReplyFeatureMap = new HashMap<>();
     95 
     96     MapMessageMonitor(Context context) {
     97         mContext = context;
     98         mBluetoothMapReceiver = new BluetoothMapReceiver();
     99         mBluetoothSdpReceiver = new BluetoothSdpReceiver();
    100         mNotificationManager =
    101                 (NotificationManager) mContext.getSystemService(Context.NOTIFICATION_SERVICE);
    102         mTTSHelper = new TTSHelper(mContext);
    103     }
    104 
    105     public boolean isPlaying() {
    106         return mTTSHelper.isSpeaking();
    107     }
    108 
    109     private void handleNewMessage(Intent intent) {
    110         if (DBG) {
    111             Log.d(TAG, "Handling new message");
    112         }
    113         try {
    114             MapMessage message = MapMessage.parseFrom(intent);
    115             if (MessengerService.DBG) {
    116                 Log.v(TAG, "Parsed message: " + message);
    117             }
    118             MessageKey messageKey = new MessageKey(message);
    119             boolean repeatMessage = mMessages.containsKey(messageKey);
    120             mMessages.put(messageKey, message);
    121             if (!repeatMessage) {
    122                 updateNotificationInfo(message, messageKey);
    123             }
    124         } catch (IllegalArgumentException e) {
    125             Log.e(TAG, "Dropping invalid MAP message", e);
    126         }
    127     }
    128 
    129     private void updateNotificationInfo(MapMessage message, MessageKey messageKey) {
    130         SenderKey senderKey = new SenderKey(message);
    131         // check the version/feature of the
    132         BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter();
    133         adapter.getRemoteDevice(senderKey.mDeviceAddress).sdpSearch(BluetoothUuid.MAS);
    134 
    135         NotificationInfo notificationInfo = mNotificationInfos.get(senderKey);
    136         if (notificationInfo == null) {
    137             notificationInfo =
    138                     new NotificationInfo(message.getSenderName(), message.getSenderContactUri());
    139             mNotificationInfos.put(senderKey, notificationInfo);
    140         }
    141         notificationInfo.mMessageKeys.add(messageKey);
    142         updateNotificationFor(senderKey, notificationInfo);
    143     }
    144 
    145     private static final String[] CONTACT_ID = new String[] {
    146             ContactsContract.PhoneLookup._ID
    147     };
    148 
    149     private static int getContactIdFromName(ContentResolver cr, String name) {
    150         if (DBG) {
    151             Log.d(TAG, "getting contactId for: " + name);
    152         }
    153         if (TextUtils.isEmpty(name)) {
    154             return 0;
    155         }
    156 
    157         String[] mSelectionArgs = { name };
    158 
    159         Cursor cursor =
    160                 cr.query(
    161                         ContactsContract.Contacts.CONTENT_URI,
    162                         CONTACT_ID,
    163                         ContactsContract.Contacts.DISPLAY_NAME_PRIMARY + " LIKE ?",
    164                         mSelectionArgs,
    165                         null);
    166         try {
    167             if (cursor != null && cursor.moveToFirst()) {
    168                 int id = cursor.getInt(cursor.getColumnIndex(ContactsContract.PhoneLookup._ID));
    169                 return id;
    170             }
    171         } finally {
    172             if (cursor != null) {
    173                 cursor.close();
    174             }
    175         }
    176         return 0;
    177     }
    178 
    179     private void updateNotificationFor(SenderKey senderKey, NotificationInfo notificationInfo) {
    180         if (DBG) {
    181             Log.d(TAG, "updateNotificationFor" + notificationInfo);
    182         }
    183         String contentText = mContext.getResources().getQuantityString(
    184                 R.plurals.notification_new_message, notificationInfo.mMessageKeys.size(),
    185                 notificationInfo.mMessageKeys.size());
    186         long lastReceivedTimeMs =
    187                 mMessages.get(notificationInfo.mMessageKeys.getLast()).getReceivedTimeMs();
    188 
    189         Uri photoUri = ContentUris.withAppendedId(
    190                 ContactsContract.Contacts.CONTENT_URI, getContactIdFromName(
    191                         mContext.getContentResolver(), notificationInfo.mSenderName));
    192         if (DBG) {
    193             Log.d(TAG, "start Glide loading... " + photoUri);
    194         }
    195         Glide.with(mContext)
    196                 .asBitmap()
    197                 .load(photoUri)
    198                 .apply(RequestOptions.circleCropTransform())
    199                 .into(new SimpleTarget<Bitmap>() {
    200                     @Override
    201                     public void onResourceReady(Bitmap bitmap,
    202                             Transition<? super Bitmap> transition) {
    203                         sendNotification(bitmap);
    204                     }
    205 
    206                     @Override
    207                     public void onLoadFailed(@Nullable Drawable fallback) {
    208                         sendNotification(null);
    209                     }
    210 
    211                     private void sendNotification(Bitmap bitmap) {
    212                         if (DBG) {
    213                             Log.d(TAG, "Glide loaded. " + bitmap);
    214                         }
    215                         if (bitmap == null) {
    216                             LetterTileDrawable letterTileDrawable =
    217                                     new LetterTileDrawable(mContext.getResources());
    218                             letterTileDrawable.setContactDetails(
    219                                     notificationInfo.mSenderName, notificationInfo.mSenderName);
    220                             letterTileDrawable.setIsCircular(true);
    221                             bitmap = letterTileDrawable.toBitmap(
    222                                     mContext.getResources().getDimensionPixelSize(
    223                                             R.dimen.notification_contact_photo_size));
    224                         }
    225                         PendingIntent LaunchPlayMessageActivityIntent = PendingIntent.getActivity(
    226                                 mContext,
    227                                 REQUEST_CODE_VOICE_PLATE,
    228                                 getPlayMessageIntent(senderKey, notificationInfo),
    229                                 0);
    230 
    231                         Notification.Builder builder = new Notification.Builder(
    232                                 mContext, NotificationChannel.DEFAULT_CHANNEL_ID)
    233                                         .setContentIntent(LaunchPlayMessageActivityIntent)
    234                                         .setLargeIcon(bitmap)
    235                                         .setSmallIcon(R.drawable.ic_message)
    236                                         .setContentTitle(notificationInfo.mSenderName)
    237                                         .setContentText(contentText)
    238                                         .setWhen(lastReceivedTimeMs)
    239                                         .setShowWhen(true)
    240                                         .setActions(getActionsFor(senderKey, notificationInfo))
    241                                         .setDeleteIntent(buildIntentFor(
    242                                                 MessengerService.ACTION_CLEAR_NOTIFICATION_STATE,
    243                                                 senderKey, notificationInfo));
    244                         if (notificationInfo.muted) {
    245                             builder.setPriority(Notification.PRIORITY_MIN);
    246                         } else {
    247                             builder.setPriority(Notification.PRIORITY_HIGH)
    248                                     .setSound(Settings.System.DEFAULT_NOTIFICATION_URI);
    249                         }
    250                         mNotificationManager.notify(
    251                                 notificationInfo.mNotificationId, builder.build());
    252                     }
    253                 });
    254     }
    255 
    256     private Intent getPlayMessageIntent(SenderKey senderKey, NotificationInfo notificationInfo) {
    257         Intent intent = new Intent(mContext, PlayMessageActivity.class);
    258         intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
    259         intent.putExtra(PlayMessageActivity.EXTRA_MESSAGE_KEY, senderKey);
    260         intent.putExtra(
    261                 PlayMessageActivity.EXTRA_SENDER_NAME,
    262                 notificationInfo.mSenderName);
    263         if (!supportsReply(senderKey.mDeviceAddress)) {
    264             intent.putExtra(
    265                     PlayMessageActivity.EXTRA_REPLY_DISABLED_FLAG,
    266                     true);
    267         }
    268         return intent;
    269     }
    270 
    271     private boolean supportsReply(String deviceAddress) {
    272         return mReplyFeatureMap.containsKey(deviceAddress)
    273                 && mReplyFeatureMap.get(deviceAddress);
    274     }
    275 
    276     private Notification.Action[] getActionsFor(
    277             SenderKey senderKey,
    278             NotificationInfo notificationInfo) {
    279         // Icon doesn't appear to be used; using fixed icon for all actions.
    280         final Icon icon = Icon.createWithResource(mContext, android.R.drawable.ic_media_play);
    281 
    282         List<Notification.Action.Builder> builders = new ArrayList<>(ACTION_COUNT);
    283 
    284         // show auto reply options of device supports it
    285         if (supportsReply(senderKey.mDeviceAddress)) {
    286             Intent replyIntent = getPlayMessageIntent(senderKey, notificationInfo);
    287             replyIntent.putExtra(PlayMessageActivity.EXTRA_SHOW_REPLY_LIST_FLAG, true);
    288             PendingIntent autoReplyIntent = PendingIntent.getActivity(
    289                     mContext, REQUEST_CODE_AUTO_REPLY, replyIntent, 0);
    290             builders.add(new Notification.Action.Builder(icon,
    291                     mContext.getString(R.string.action_reply), autoReplyIntent));
    292         }
    293 
    294         // add mute/unmute.
    295         if (notificationInfo.muted) {
    296             PendingIntent muteIntent = buildIntentFor(MessengerService.ACTION_UNMUTE_CONVERSATION,
    297                     senderKey, notificationInfo);
    298             builders.add(new Notification.Action.Builder(icon,
    299                     mContext.getString(R.string.action_unmute), muteIntent));
    300         } else {
    301             PendingIntent muteIntent = buildIntentFor(MessengerService.ACTION_MUTE_CONVERSATION,
    302                     senderKey, notificationInfo);
    303             builders.add(new Notification.Action.Builder(icon,
    304                     mContext.getString(R.string.action_mute), muteIntent));
    305         }
    306 
    307         Notification.Action actions[] = new Notification.Action[builders.size()];
    308         for (int i = 0; i < builders.size(); i++) {
    309             actions[i] = builders.get(i).build();
    310         }
    311         return actions;
    312     }
    313 
    314     private PendingIntent buildIntentFor(String action, SenderKey senderKey,
    315             NotificationInfo notificationInfo) {
    316         Intent intent = new Intent(mContext, MessengerService.class)
    317                 .setAction(action)
    318                 .putExtra(MessengerService.EXTRA_SENDER_KEY, senderKey);
    319         return PendingIntent.getService(mContext,
    320                 notificationInfo.mNotificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT);
    321     }
    322 
    323     void clearNotificationState(SenderKey senderKey) {
    324         if (DBG) {
    325             Log.d(TAG, "Clearing notification state for: " + senderKey);
    326         }
    327         mNotificationInfos.remove(senderKey);
    328     }
    329 
    330     void playMessages(SenderKey senderKey) {
    331         NotificationInfo notificationInfo = mNotificationInfos.get(senderKey);
    332         if (notificationInfo == null) {
    333             Log.e(TAG, "Unknown senderKey! " + senderKey);
    334             return;
    335         }
    336         List<CharSequence> ttsMessages = new ArrayList<>();
    337         // TODO: play unread messages instead of the last.
    338         String ttsMessage =
    339                 notificationInfo.mMessageKeys.stream().map((key) -> mMessages.get(key).getText())
    340                         .collect(Collectors.toCollection(LinkedList::new)).getLast();
    341         // Insert something like "foo says" before their message content.
    342         ttsMessages.add(mContext.getString(R.string.tts_sender_says, notificationInfo.mSenderName));
    343         ttsMessages.add(ttsMessage);
    344 
    345         mTTSHelper.requestPlay(ttsMessages,
    346                 new TTSHelper.Listener() {
    347                     @Override
    348                     public void onTTSStarted() {
    349                         Intent intent = new Intent(ACTION_MESSAGE_PLAY_START);
    350                         mContext.sendBroadcast(intent);
    351                     }
    352 
    353                     @Override
    354                     public void onTTSStopped(boolean error) {
    355                         Intent intent = new Intent(ACTION_MESSAGE_PLAY_STOP);
    356                         mContext.sendBroadcast(intent);
    357                         if (error) {
    358                             Toast.makeText(mContext, R.string.tts_failed_toast,
    359                                     Toast.LENGTH_SHORT).show();
    360                         }
    361                     }
    362 
    363                     @Override
    364                     public void onAudioFocusFailed() {
    365                         Log.w(TAG, "failed to require audio focus.");
    366                     }
    367                 });
    368     }
    369 
    370     void stopPlayout() {
    371         mTTSHelper.requestStop();
    372     }
    373 
    374     void toggleMuteConversation(SenderKey senderKey, boolean mute) {
    375         NotificationInfo notificationInfo = mNotificationInfos.get(senderKey);
    376         if (notificationInfo == null) {
    377             Log.e(TAG, "Unknown senderKey! " + senderKey);
    378             return;
    379         }
    380         notificationInfo.muted = mute;
    381         updateNotificationFor(senderKey, notificationInfo);
    382     }
    383 
    384     boolean sendAutoReply(SenderKey senderKey, BluetoothMapClient mapClient, String message) {
    385         if (DBG) {
    386             Log.d(TAG, "Sending auto-reply to: " + senderKey);
    387         }
    388         BluetoothDevice device =
    389                 BluetoothAdapter.getDefaultAdapter().getRemoteDevice(senderKey.mDeviceAddress);
    390         NotificationInfo notificationInfo = mNotificationInfos.get(senderKey);
    391         if (notificationInfo == null) {
    392             Log.w(TAG, "No notificationInfo found for senderKey: " + senderKey);
    393             return false;
    394         }
    395         if (notificationInfo.mSenderContactUri == null) {
    396             Log.w(TAG, "Do not have contact URI for sender!");
    397             return false;
    398         }
    399         Uri recipientUris[] = { Uri.parse(notificationInfo.mSenderContactUri) };
    400 
    401         final int requestCode = senderKey.hashCode();
    402         PendingIntent sentIntent =
    403                 PendingIntent.getBroadcast(mContext, requestCode, new Intent(
    404                                 BluetoothMapClient.ACTION_MESSAGE_SENT_SUCCESSFULLY),
    405                         PendingIntent.FLAG_ONE_SHOT);
    406         return mapClient.sendMessage(device, recipientUris, message, sentIntent, null);
    407     }
    408 
    409     void handleMapDisconnect() {
    410         cleanupMessagesAndNotifications((key) -> true);
    411     }
    412 
    413     void handleDeviceDisconnect(BluetoothDevice device) {
    414         cleanupMessagesAndNotifications((key) -> key.matches(device.getAddress()));
    415     }
    416 
    417     private void cleanupMessagesAndNotifications(Predicate<CompositeKey> predicate) {
    418         Iterator<Map.Entry<MessageKey, MapMessage>> messageIt = mMessages.entrySet().iterator();
    419         while (messageIt.hasNext()) {
    420             if (predicate.test(messageIt.next().getKey())) {
    421                 messageIt.remove();
    422             }
    423         }
    424         Iterator<Map.Entry<SenderKey, NotificationInfo>> notificationIt =
    425                 mNotificationInfos.entrySet().iterator();
    426         while (notificationIt.hasNext()) {
    427             Map.Entry<SenderKey, NotificationInfo> entry = notificationIt.next();
    428             if (predicate.test(entry.getKey())) {
    429                 mNotificationManager.cancel(entry.getValue().mNotificationId);
    430                 notificationIt.remove();
    431             }
    432         }
    433     }
    434 
    435     void cleanup() {
    436         mBluetoothMapReceiver.cleanup();
    437         mBluetoothSdpReceiver.cleanup();
    438         mTTSHelper.cleanup();
    439     }
    440 
    441     private class BluetoothSdpReceiver extends BroadcastReceiver {
    442         BluetoothSdpReceiver() {
    443             if (DBG) {
    444                 Log.d(TAG, "Registering receiver for sdp");
    445             }
    446             IntentFilter intentFilter = new IntentFilter();
    447             intentFilter.addAction(BluetoothDevice.ACTION_SDP_RECORD);
    448             mContext.registerReceiver(this, intentFilter);
    449         }
    450 
    451         void cleanup() {
    452             mContext.unregisterReceiver(this);
    453         }
    454 
    455         @Override
    456         public void onReceive(Context context, Intent intent) {
    457             if (BluetoothDevice.ACTION_SDP_RECORD.equals(intent.getAction())) {
    458                 if (DBG) {
    459                     Log.d(TAG, "get SDP record: " + intent.getExtras());
    460                 }
    461                 Parcelable parcelable = intent.getParcelableExtra(BluetoothDevice.EXTRA_SDP_RECORD);
    462                 if (!(parcelable instanceof SdpMasRecord)) {
    463                     if (DBG) {
    464                         Log.d(TAG, "not SdpMasRecord: " + parcelable);
    465                     }
    466                     return;
    467                 }
    468                 SdpMasRecord masRecord = (SdpMasRecord) parcelable;
    469                 int features = masRecord.getSupportedFeatures();
    470                 int version = masRecord.getProfileVersion();
    471                 boolean supportsReply = false;
    472                 // we only consider the device supports reply feature of the version
    473                 // is higher than 1.02 and the feature flag is turned on.
    474                 if (version >= 0x102 && isOn(features, REPLY_FEATURE_POS)) {
    475                     supportsReply = true;
    476                 }
    477                 BluetoothDevice bluetoothDevice =
    478                         intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE);
    479                 mReplyFeatureMap.put(bluetoothDevice.getAddress(), supportsReply);
    480             } else {
    481                 Log.w(TAG, "Ignoring unknown broadcast " + intent.getAction());
    482             }
    483         }
    484 
    485         private boolean isOn(int input, int postion) {
    486             return ((input >> postion) & 1) == 1;
    487         }
    488     }
    489 
    490     // Used to monitor for new incoming messages and sent-message broadcast.
    491     private class BluetoothMapReceiver extends BroadcastReceiver {
    492         BluetoothMapReceiver() {
    493             if (DBG) {
    494                 Log.d(TAG, "Registering receiver for bluetooth MAP");
    495             }
    496             IntentFilter intentFilter = new IntentFilter();
    497             intentFilter.addAction(BluetoothMapClient.ACTION_MESSAGE_SENT_SUCCESSFULLY);
    498             intentFilter.addAction(BluetoothMapClient.ACTION_MESSAGE_RECEIVED);
    499             mContext.registerReceiver(this, intentFilter);
    500         }
    501 
    502         void cleanup() {
    503             mContext.unregisterReceiver(this);
    504         }
    505 
    506         @Override
    507         public void onReceive(Context context, Intent intent) {
    508             if (BluetoothMapClient.ACTION_MESSAGE_SENT_SUCCESSFULLY.equals(intent.getAction())) {
    509                 if (DBG) {
    510                     Log.d(TAG, "SMS was sent successfully!");
    511                 }
    512             } else if (BluetoothMapClient.ACTION_MESSAGE_RECEIVED.equals(intent.getAction())) {
    513                 if (DBG) {
    514                     Log.d(TAG, "SMS message received");
    515                 }
    516                 handleNewMessage(intent);
    517             } else {
    518                 Log.w(TAG, "Ignoring unknown broadcast " + intent.getAction());
    519             }
    520         }
    521     }
    522 
    523     /**
    524      * Key used in HashMap that is composed from a BT device-address and device-specific "sub key"
    525      */
    526     private abstract static class CompositeKey {
    527         final String mDeviceAddress;
    528         final String mSubKey;
    529 
    530         CompositeKey(String deviceAddress, String subKey) {
    531             mDeviceAddress = deviceAddress;
    532             mSubKey = subKey;
    533         }
    534 
    535         @Override
    536         public boolean equals(Object o) {
    537             if (this == o) {
    538                 return true;
    539             }
    540             if (o == null || getClass() != o.getClass()) {
    541                 return false;
    542             }
    543 
    544             CompositeKey that = (CompositeKey) o;
    545             return Objects.equals(mDeviceAddress, that.mDeviceAddress)
    546                     && Objects.equals(mSubKey, that.mSubKey);
    547         }
    548 
    549         boolean matches(String deviceAddress) {
    550             return mDeviceAddress.equals(deviceAddress);
    551         }
    552 
    553         @Override
    554         public int hashCode() {
    555             return Objects.hash(mDeviceAddress, mSubKey);
    556         }
    557 
    558         @Override
    559         public String toString() {
    560             return String.format("%s, deviceAddress: %s, subKey: %s",
    561                     getClass().getSimpleName(), mDeviceAddress, mSubKey);
    562         }
    563     }
    564 
    565     /**
    566      * {@link CompositeKey} subclass used to identify specific messages; it uses message-handle as
    567      * the secondary key.
    568      */
    569     private static class MessageKey extends CompositeKey {
    570         MessageKey(MapMessage message) {
    571             super(message.getDevice().getAddress(), message.getHandle());
    572         }
    573     }
    574 
    575     /**
    576      * CompositeKey used to identify Notification info for a sender; it uses a combination of
    577      * senderContactUri and senderContactName as the secondary key.
    578      */
    579     static class SenderKey extends CompositeKey implements Parcelable {
    580         private SenderKey(String deviceAddress, String key) {
    581             super(deviceAddress, key);
    582         }
    583 
    584         SenderKey(MapMessage message) {
    585             // Use a combination of senderName and senderContactUri for key. Ideally we would use
    586             // only senderContactUri (which is encoded phone no.). However since some phones don't
    587             // provide these, we fall back to senderName. Since senderName may not be unique, we
    588             // include senderContactUri also to provide uniqueness in cases it is available.
    589             this(message.getDevice().getAddress(),
    590                     message.getSenderName() + "/" + message.getSenderContactUri());
    591         }
    592 
    593         @Override
    594         public int describeContents() {
    595             return 0;
    596         }
    597 
    598         @Override
    599         public void writeToParcel(Parcel dest, int flags) {
    600             dest.writeString(mDeviceAddress);
    601             dest.writeString(mSubKey);
    602         }
    603 
    604         public static final Parcelable.Creator<SenderKey> CREATOR =
    605                 new Parcelable.Creator<SenderKey>() {
    606                     @Override
    607                     public SenderKey createFromParcel(Parcel source) {
    608                         return new SenderKey(source.readString(), source.readString());
    609                     }
    610 
    611                     @Override
    612                     public SenderKey[] newArray(int size) {
    613                         return new SenderKey[size];
    614                     }
    615                 };
    616     }
    617 
    618     /**
    619      * Information about a single notification that is displayed.
    620      */
    621     private static class NotificationInfo {
    622         private static int NEXT_NOTIFICATION_ID = 0;
    623 
    624         final int mNotificationId = NEXT_NOTIFICATION_ID++;
    625         final String mSenderName;
    626         @Nullable
    627         final String mSenderContactUri;
    628         final LinkedList<MessageKey> mMessageKeys = new LinkedList<>();
    629         boolean muted = false;
    630 
    631         NotificationInfo(String senderName, @Nullable String senderContactUri) {
    632             mSenderName = senderName;
    633             mSenderContactUri = senderContactUri;
    634         }
    635     }
    636 }
    637