• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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 
MapMessageMonitor(Context context)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 
isPlaying()105     public boolean isPlaying() {
106         return mTTSHelper.isSpeaking();
107     }
108 
handleNewMessage(Intent intent)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 
updateNotificationInfo(MapMessage message, MessageKey messageKey)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 
getContactIdFromName(ContentResolver cr, String name)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 
updateNotificationFor(SenderKey senderKey, NotificationInfo notificationInfo)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 
getPlayMessageIntent(SenderKey senderKey, NotificationInfo notificationInfo)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 
supportsReply(String deviceAddress)271     private boolean supportsReply(String deviceAddress) {
272         return mReplyFeatureMap.containsKey(deviceAddress)
273                 && mReplyFeatureMap.get(deviceAddress);
274     }
275 
getActionsFor( SenderKey senderKey, NotificationInfo notificationInfo)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 
buildIntentFor(String action, SenderKey senderKey, NotificationInfo notificationInfo)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 
clearNotificationState(SenderKey senderKey)323     void clearNotificationState(SenderKey senderKey) {
324         if (DBG) {
325             Log.d(TAG, "Clearing notification state for: " + senderKey);
326         }
327         mNotificationInfos.remove(senderKey);
328     }
329 
playMessages(SenderKey senderKey)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 
stopPlayout()370     void stopPlayout() {
371         mTTSHelper.requestStop();
372     }
373 
toggleMuteConversation(SenderKey senderKey, boolean mute)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 
sendAutoReply(SenderKey senderKey, BluetoothMapClient mapClient, String message)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 
handleMapDisconnect()409     void handleMapDisconnect() {
410         cleanupMessagesAndNotifications((key) -> true);
411     }
412 
handleDeviceDisconnect(BluetoothDevice device)413     void handleDeviceDisconnect(BluetoothDevice device) {
414         cleanupMessagesAndNotifications((key) -> key.matches(device.getAddress()));
415     }
416 
cleanupMessagesAndNotifications(Predicate<CompositeKey> predicate)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 
cleanup()435     void cleanup() {
436         mBluetoothMapReceiver.cleanup();
437         mBluetoothSdpReceiver.cleanup();
438         mTTSHelper.cleanup();
439     }
440 
441     private class BluetoothSdpReceiver extends BroadcastReceiver {
BluetoothSdpReceiver()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 
cleanup()451         void cleanup() {
452             mContext.unregisterReceiver(this);
453         }
454 
455         @Override
onReceive(Context context, Intent intent)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 
isOn(int input, int postion)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 {
BluetoothMapReceiver()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 
cleanup()502         void cleanup() {
503             mContext.unregisterReceiver(this);
504         }
505 
506         @Override
onReceive(Context context, Intent intent)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 
CompositeKey(String deviceAddress, String subKey)530         CompositeKey(String deviceAddress, String subKey) {
531             mDeviceAddress = deviceAddress;
532             mSubKey = subKey;
533         }
534 
535         @Override
equals(Object o)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 
matches(String deviceAddress)549         boolean matches(String deviceAddress) {
550             return mDeviceAddress.equals(deviceAddress);
551         }
552 
553         @Override
hashCode()554         public int hashCode() {
555             return Objects.hash(mDeviceAddress, mSubKey);
556         }
557 
558         @Override
toString()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 {
MessageKey(MapMessage message)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 {
SenderKey(String deviceAddress, String key)580         private SenderKey(String deviceAddress, String key) {
581             super(deviceAddress, key);
582         }
583 
SenderKey(MapMessage message)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
describeContents()594         public int describeContents() {
595             return 0;
596         }
597 
598         @Override
writeToParcel(Parcel dest, int flags)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 
NotificationInfo(String senderName, @Nullable String senderContactUri)631         NotificationInfo(String senderName, @Nullable String senderContactUri) {
632             mSenderName = senderName;
633             mSenderContactUri = senderContactUri;
634         }
635     }
636 }
637