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