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