1/* 2 * Copyright (C) 2015 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 17package com.android.messaging.datamodel; 18 19import android.app.Notification; 20import android.app.PendingIntent; 21import android.content.Context; 22import android.content.Intent; 23import android.content.pm.PackageManager.NameNotFoundException; 24import android.content.res.Resources; 25import android.graphics.Bitmap; 26import android.graphics.Bitmap.Config; 27import android.graphics.BitmapFactory; 28import android.graphics.Typeface; 29import android.media.AudioManager; 30import android.net.Uri; 31import android.os.Bundle; 32import android.os.SystemClock; 33import android.provider.ContactsContract; 34import android.provider.ContactsContract.Contacts; 35import android.support.v4.app.NotificationCompat; 36import android.support.v4.app.NotificationCompat.WearableExtender; 37import android.support.v4.app.NotificationManagerCompat; 38import android.support.v4.app.RemoteInput; 39import android.support.v4.util.SimpleArrayMap; 40import android.text.Spannable; 41import android.text.SpannableStringBuilder; 42import android.text.TextUtils; 43import android.text.style.StyleSpan; 44import android.text.style.TextAppearanceSpan; 45 46import com.android.messaging.Factory; 47import com.android.messaging.R; 48import com.android.messaging.datamodel.MessageNotificationState.BundledMessageNotificationState; 49import com.android.messaging.datamodel.MessageNotificationState.ConversationLineInfo; 50import com.android.messaging.datamodel.MessageNotificationState.MultiConversationNotificationState; 51import com.android.messaging.datamodel.MessageNotificationState.MultiMessageNotificationState; 52import com.android.messaging.datamodel.action.MarkAsReadAction; 53import com.android.messaging.datamodel.action.MarkAsSeenAction; 54import com.android.messaging.datamodel.action.RedownloadMmsAction; 55import com.android.messaging.datamodel.data.ConversationListItemData; 56import com.android.messaging.datamodel.media.AvatarRequestDescriptor; 57import com.android.messaging.datamodel.media.ImageResource; 58import com.android.messaging.datamodel.media.MediaRequest; 59import com.android.messaging.datamodel.media.MediaResourceManager; 60import com.android.messaging.datamodel.media.MessagePartVideoThumbnailRequestDescriptor; 61import com.android.messaging.datamodel.media.UriImageRequestDescriptor; 62import com.android.messaging.datamodel.media.VideoThumbnailRequest; 63import com.android.messaging.sms.MmsSmsUtils; 64import com.android.messaging.sms.MmsUtils; 65import com.android.messaging.ui.UIIntents; 66import com.android.messaging.util.Assert; 67import com.android.messaging.util.AvatarUriUtil; 68import com.android.messaging.util.BugleGservices; 69import com.android.messaging.util.BugleGservicesKeys; 70import com.android.messaging.util.BuglePrefs; 71import com.android.messaging.util.BuglePrefsKeys; 72import com.android.messaging.util.ContentType; 73import com.android.messaging.util.ConversationIdSet; 74import com.android.messaging.util.ImageUtils; 75import com.android.messaging.util.LogUtil; 76import com.android.messaging.util.NotificationPlayer; 77import com.android.messaging.util.OsUtil; 78import com.android.messaging.util.PendingIntentConstants; 79import com.android.messaging.util.PhoneUtils; 80import com.android.messaging.util.RingtoneUtil; 81import com.android.messaging.util.ThreadUtil; 82import com.android.messaging.util.UriUtil; 83 84import java.util.HashSet; 85import java.util.Iterator; 86import java.util.List; 87import java.util.Locale; 88import java.util.Set; 89 90/** 91 * Handle posting, updating and removing all conversation notifications. 92 * 93 * There are currently two main classes of notification and their rules: <p> 94 * 1) Messages - {@link MessageNotificationState}. Only one message notification. 95 * Unread messages across senders and conversations are coalesced.<p> 96 * 2) Failed Messages - {@link MessageNotificationState#checkFailedMesages } Only one failed 97 * message. Multiple failures are coalesced.<p> 98 * 99 * To add a new class of notifications, subclass the NotificationState and add commands which 100 * create one and pass into general creation function. 101 * 102 */ 103public class BugleNotifications { 104 // Logging 105 public static final String TAG = LogUtil.BUGLE_NOTIFICATIONS_TAG; 106 107 // Constants to use for update. 108 public static final int UPDATE_NONE = 0; 109 public static final int UPDATE_MESSAGES = 1; 110 public static final int UPDATE_ERRORS = 2; 111 public static final int UPDATE_ALL = UPDATE_MESSAGES + UPDATE_ERRORS; 112 113 // Constants for notification type used for audio and vibration settings. 114 public static final int LOCAL_SMS_NOTIFICATION = 0; 115 116 private static final String SMS_NOTIFICATION_TAG = ":sms:"; 117 private static final String SMS_ERROR_NOTIFICATION_TAG = ":error:"; 118 119 private static final String WEARABLE_COMPANION_APP_PACKAGE = "com.google.android.wearable.app"; 120 121 private static final Set<NotificationState> sPendingNotifications = 122 new HashSet<NotificationState>(); 123 124 private static int sWearableImageWidth; 125 private static int sWearableImageHeight; 126 private static int sIconWidth; 127 private static int sIconHeight; 128 129 private static boolean sInitialized = false; 130 131 private static final Object mLock = new Object(); 132 133 // sLastMessageDingTime is a map between a conversation id and a time. It's used to keep track 134 // of the time we last dinged a message for this conversation. When messages are coming in 135 // at flurry, we don't want to over-ding the user. 136 private static final SimpleArrayMap<String, Long> sLastMessageDingTime = 137 new SimpleArrayMap<String, Long>(); 138 private static int sTimeBetweenDingsMs; 139 140 /** 141 * This is the volume at which to play the observable-conversation notification sound, 142 * expressed as a fraction of the system notification volume. 143 */ 144 private static final float OBSERVABLE_CONVERSATION_NOTIFICATION_VOLUME = 0.25f; 145 146 /** 147 * Entry point for posting notifications. 148 * Don't call this on the UI thread. 149 * @param silent If true, no ring will be played. If false, checks global settings before 150 * playing a ringtone 151 * @param coverage Indicates which notification types should be checked. Valid values are 152 * UPDATE_NONE, UPDATE_MESSAGES, UPDATE_ERRORS, or UPDATE_ALL 153 */ 154 public static void update(final boolean silent, final int coverage) { 155 update(silent, null /* conversationId */, coverage); 156 } 157 158 /** 159 * Entry point for posting notifications. 160 * Don't call this on the UI thread. 161 * @param silent If true, no ring will be played. If false, checks global settings before 162 * playing a ringtone 163 * @param conversationId Conversation ID where a new message was received 164 * @param coverage Indicates which notification types should be checked. Valid values are 165 * UPDATE_NONE, UPDATE_MESSAGES, UPDATE_ERRORS, or UPDATE_ALL 166 */ 167 public static void update(final boolean silent, final String conversationId, 168 final int coverage) { 169 if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { 170 LogUtil.v(TAG, "Update: silent = " + silent 171 + " conversationId = " + conversationId 172 + " coverage = " + coverage); 173 } 174 Assert.isNotMainThread(); 175 checkInitialized(); 176 177 if (!shouldNotify()) { 178 if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { 179 LogUtil.v(TAG, "Notifications disabled"); 180 } 181 cancel(PendingIntentConstants.SMS_NOTIFICATION_ID); 182 return; 183 } else { 184 if ((coverage & UPDATE_MESSAGES) != 0) { 185 createMessageNotification(silent, conversationId); 186 } 187 } 188 if ((coverage & UPDATE_ERRORS) != 0) { 189 MessageNotificationState.checkFailedMessages(); 190 } 191 } 192 193 /** 194 * Cancel all notifications of a certain type. 195 * 196 * @param type Message or error notifications from Constants. 197 */ 198 private static synchronized void cancel(final int type) { 199 cancel(type, null, false); 200 } 201 202 /** 203 * Cancel all notifications of a certain type. 204 * 205 * @param type Message or error notifications from Constants. 206 * @param conversationId If set, cancel the notification for this 207 * conversation only. For message notifications, this only works 208 * if the notifications are bundled (group children). 209 * @param isBundledNotification True if this notification is part of a 210 * notification bundle. This only applies to message notifications, 211 * which are bundled together with other message notifications. 212 */ 213 private static synchronized void cancel(final int type, final String conversationId, 214 final boolean isBundledNotification) { 215 final String notificationTag = buildNotificationTag(type, conversationId, 216 isBundledNotification); 217 final NotificationManagerCompat notificationManager = 218 NotificationManagerCompat.from(Factory.get().getApplicationContext()); 219 220 // Find all pending notifications and cancel them. 221 synchronized (sPendingNotifications) { 222 final Iterator<NotificationState> iter = sPendingNotifications.iterator(); 223 while (iter.hasNext()) { 224 final NotificationState notifState = iter.next(); 225 if (notifState.mType == type) { 226 notifState.mCanceled = true; 227 if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { 228 LogUtil.v(TAG, "Canceling pending notification"); 229 } 230 iter.remove(); 231 } 232 } 233 } 234 notificationManager.cancel(notificationTag, type); 235 if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { 236 LogUtil.d(TAG, "Canceled notifications of type " + type); 237 } 238 239 // Message notifications for multiple conversations can be grouped together (see comment in 240 // createMessageNotification). We need to do bookkeeping to track the current set of 241 // notification group children, including removing them when we cancel notifications). 242 if (type == PendingIntentConstants.SMS_NOTIFICATION_ID) { 243 final Context context = Factory.get().getApplicationContext(); 244 final ConversationIdSet groupChildIds = getGroupChildIds(context); 245 246 if (groupChildIds != null && groupChildIds.size() > 0) { 247 // If a conversation is specified, remove just that notification. Otherwise, 248 // we're removing the group summary so clear all children. 249 if (conversationId != null) { 250 groupChildIds.remove(conversationId); 251 writeGroupChildIds(context, groupChildIds); 252 } else { 253 cancelStaleGroupChildren(groupChildIds, null); 254 // We'll update the group children preference as we cancel each child, 255 // so we don't need to do it here. 256 } 257 } 258 } 259 } 260 261 /** 262 * Cancels stale notifications from the currently active group of 263 * notifications. If the {@code state} parameter is an instance of 264 * {@link MultiConversationNotificationState} it represents a new 265 * notification group. This method will cancel any notifications that were 266 * in the old group, but not the new one. If the new notification is not a 267 * group, then all existing grouped notifications are cancelled. 268 * 269 * @param previousGroupChildren Conversation ids for the active notification 270 * group 271 * @param state New notification state 272 */ 273 private static void cancelStaleGroupChildren(final ConversationIdSet previousGroupChildren, 274 final NotificationState state) { 275 final ConversationIdSet newChildren = new ConversationIdSet(); 276 if (state instanceof MultiConversationNotificationState) { 277 for (final NotificationState child : 278 ((MultiConversationNotificationState) state).mChildren) { 279 if (child.mConversationIds != null) { 280 newChildren.add(child.mConversationIds.first()); 281 } 282 } 283 } 284 for (final String childConversationId : previousGroupChildren) { 285 if (!newChildren.contains(childConversationId)) { 286 cancel(PendingIntentConstants.SMS_NOTIFICATION_ID, childConversationId, true); 287 } 288 } 289 } 290 291 /** 292 * Returns {@code true} if incoming notifications should display a 293 * notification, {@code false} otherwise. 294 * 295 * @return true if the notification should occur 296 */ 297 private static boolean shouldNotify() { 298 // If we're not the default sms app, don't put up any notifications. 299 if (!PhoneUtils.getDefault().isDefaultSmsApp()) { 300 return false; 301 } 302 303 // Now check prefs (i.e. settings) to see if the user turned off notifications. 304 final BuglePrefs prefs = BuglePrefs.getApplicationPrefs(); 305 final Context context = Factory.get().getApplicationContext(); 306 final String prefKey = context.getString(R.string.notifications_enabled_pref_key); 307 final boolean defaultValue = context.getResources().getBoolean( 308 R.bool.notifications_enabled_pref_default); 309 return prefs.getBoolean(prefKey, defaultValue); 310 } 311 312 /** 313 * Returns {@code true} if incoming notifications for the given {@link NotificationState} 314 * should vibrate the device, {@code false} otherwise. 315 * 316 * @return true if vibration should be used 317 */ 318 public static boolean shouldVibrate(final NotificationState state) { 319 // The notification should vibrate if the global setting is turned on AND 320 // the per-conversation setting is turned on (default). 321 if (!state.getNotificationVibrate()) { 322 return false; 323 } else { 324 final BuglePrefs prefs = BuglePrefs.getApplicationPrefs(); 325 final Context context = Factory.get().getApplicationContext(); 326 final String prefKey = context.getString(R.string.notification_vibration_pref_key); 327 final boolean defaultValue = context.getResources().getBoolean( 328 R.bool.notification_vibration_pref_default); 329 return prefs.getBoolean(prefKey, defaultValue); 330 } 331 } 332 333 private static Uri getNotificationRingtoneUriForConversationId(final String conversationId) { 334 final DatabaseWrapper db = DataModel.get().getDatabase(); 335 final ConversationListItemData convData = 336 ConversationListItemData.getExistingConversation(db, conversationId); 337 return RingtoneUtil.getNotificationRingtoneUri( 338 convData != null ? convData.getNotificationSoundUri() : null); 339 } 340 341 /** 342 * Returns a unique tag to identify a notification. 343 * 344 * @param name The tag name (in practice, the type) 345 * @param conversationId The conversation id (optional) 346 */ 347 private static String buildNotificationTag(final String name, 348 final String conversationId) { 349 final Context context = Factory.get().getApplicationContext(); 350 if (conversationId != null) { 351 return context.getPackageName() + name + ":" + conversationId; 352 } else { 353 return context.getPackageName() + name; 354 } 355 } 356 357 /** 358 * Returns a unique tag to identify a notification. 359 * <p> 360 * This delegates to 361 * {@link #buildNotificationTag(int, String, boolean)} and can be 362 * used when the notification is never bundled (e.g. error notifications). 363 */ 364 static String buildNotificationTag(final int type, final String conversationId) { 365 return buildNotificationTag(type, conversationId, false /* bundledNotification */); 366 } 367 368 /** 369 * Returns a unique tag to identify a notification. 370 * 371 * @param type One of the constants in {@link PendingIntentConstants} 372 * @param conversationId The conversation id (where applicable) 373 * @param bundledNotification Set to true if this notification will be 374 * bundled together with other notifications (e.g. on a wearable 375 * device). 376 */ 377 static String buildNotificationTag(final int type, final String conversationId, 378 final boolean bundledNotification) { 379 String tag = null; 380 switch(type) { 381 case PendingIntentConstants.SMS_NOTIFICATION_ID: 382 if (bundledNotification) { 383 tag = buildNotificationTag(SMS_NOTIFICATION_TAG, conversationId); 384 } else { 385 tag = buildNotificationTag(SMS_NOTIFICATION_TAG, null); 386 } 387 break; 388 case PendingIntentConstants.MSG_SEND_ERROR: 389 tag = buildNotificationTag(SMS_ERROR_NOTIFICATION_TAG, null); 390 break; 391 } 392 return tag; 393 } 394 395 private static void checkInitialized() { 396 if (!sInitialized) { 397 final Resources resources = Factory.get().getApplicationContext().getResources(); 398 sWearableImageWidth = resources.getDimensionPixelSize( 399 R.dimen.notification_wearable_image_width); 400 sWearableImageHeight = resources.getDimensionPixelSize( 401 R.dimen.notification_wearable_image_height); 402 sIconHeight = (int) resources.getDimension( 403 android.R.dimen.notification_large_icon_height); 404 sIconWidth = 405 (int) resources.getDimension(android.R.dimen.notification_large_icon_width); 406 407 sInitialized = true; 408 } 409 } 410 411 private static void processAndSend(final NotificationState state, final boolean silent, 412 final boolean softSound) { 413 final Context context = Factory.get().getApplicationContext(); 414 final NotificationCompat.Builder notifBuilder = new NotificationCompat.Builder(context); 415 notifBuilder.setCategory(Notification.CATEGORY_MESSAGE); 416 // TODO: Need to fix this for multi conversation notifications to rate limit dings. 417 final String conversationId = state.mConversationIds.first(); 418 419 420 final Uri ringtoneUri = RingtoneUtil.getNotificationRingtoneUri(state.getRingtoneUri()); 421 // If the notification's conversation is currently observable (focused or in the 422 // conversation list), then play a notification beep at a low volume and don't display an 423 // actual notification. 424 if (softSound) { 425 if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { 426 LogUtil.v(TAG, "processAndSend: fromConversationId == " + 427 "sCurrentlyDisplayedConversationId so NOT showing notification," + 428 " but playing soft sound. conversationId: " + conversationId); 429 } 430 playObservableConversationNotificationSound(ringtoneUri); 431 return; 432 } 433 state.mBaseRequestCode = state.mType; 434 435 // Set the delete intent (except for bundled wearable notifications, which are dismissed 436 // as a group, either from the wearable or when the summary notification is dismissed from 437 // the host device). 438 if (!(state instanceof BundledMessageNotificationState)) { 439 final PendingIntent clearIntent = state.getClearIntent(); 440 notifBuilder.setDeleteIntent(clearIntent); 441 } 442 443 updateBuilderAudioVibrate(state, notifBuilder, silent, ringtoneUri, conversationId); 444 445 // Set the content intent 446 PendingIntent destinationIntent; 447 if (state.mConversationIds.size() > 1) { 448 // We have notifications for multiple conversation, go to the conversation list. 449 destinationIntent = UIIntents.get() 450 .getPendingIntentForConversationListActivity(context); 451 } else { 452 // We have a single conversation, go directly to that conversation. 453 destinationIntent = UIIntents.get() 454 .getPendingIntentForConversationActivity(context, 455 state.mConversationIds.first(), 456 null /*draft*/); 457 } 458 notifBuilder.setContentIntent(destinationIntent); 459 460 // TODO: set based on contact coming from a favorite. 461 notifBuilder.setPriority(state.getPriority()); 462 463 // Save the state of the notification in-progress so when the avatar is loaded, 464 // we can continue building the notification. 465 final NotificationCompat.Style notifStyle = state.build(notifBuilder); 466 state.mNotificationBuilder = notifBuilder; 467 state.mNotificationStyle = notifStyle; 468 if (!state.mPeople.isEmpty()) { 469 final Bundle people = new Bundle(); 470 people.putStringArray(NotificationCompat.EXTRA_PEOPLE, 471 state.mPeople.toArray(new String[state.mPeople.size()])); 472 notifBuilder.addExtras(people); 473 } 474 475 if (state.mParticipantAvatarsUris != null) { 476 final Uri avatarUri = state.mParticipantAvatarsUris.get(0); 477 final AvatarRequestDescriptor descriptor = new AvatarRequestDescriptor(avatarUri, 478 sIconWidth, sIconHeight, OsUtil.isAtLeastL()); 479 final MediaRequest<ImageResource> imageRequest = descriptor.buildSyncMediaRequest( 480 context); 481 482 synchronized (sPendingNotifications) { 483 sPendingNotifications.add(state); 484 } 485 486 // Synchronously load the avatar. 487 final ImageResource avatarImage = 488 MediaResourceManager.get().requestMediaResourceSync(imageRequest); 489 if (avatarImage != null) { 490 ImageResource avatarHiRes = null; 491 try { 492 if (isWearCompanionAppInstalled()) { 493 // For Wear users, we need to request a high-res avatar image to use as the 494 // notification card background. If the sender has a contact photo, we'll 495 // request the display photo from the Contacts provider. Otherwise, we ask 496 // the local content provider for a hi-res version of the generic avatar 497 // (e.g. letter with colored background). 498 avatarHiRes = requestContactDisplayPhoto(context, 499 getDisplayPhotoUri(avatarUri)); 500 if (avatarHiRes == null) { 501 final AvatarRequestDescriptor hiResDesc = 502 new AvatarRequestDescriptor(avatarUri, 503 sWearableImageWidth, 504 sWearableImageHeight, 505 false /* cropToCircle */, 506 true /* isWearBackground */); 507 avatarHiRes = MediaResourceManager.get().requestMediaResourceSync( 508 hiResDesc.buildSyncMediaRequest(context)); 509 } 510 } 511 512 // We have to make copies of the bitmaps to hand to the NotificationManager 513 // because the bitmap in the ImageResource is managed and will automatically 514 // get released. 515 Bitmap avatarBitmap = Bitmap.createBitmap(avatarImage.getBitmap()); 516 Bitmap avatarHiResBitmap = (avatarHiRes != null) ? 517 Bitmap.createBitmap(avatarHiRes.getBitmap()) : null; 518 sendNotification(state, avatarBitmap, avatarHiResBitmap); 519 return; 520 } finally { 521 avatarImage.release(); 522 if (avatarHiRes != null) { 523 avatarHiRes.release(); 524 } 525 } 526 } 527 } 528 // We have no avatar. Post the notification anyway. 529 sendNotification(state, null, null); 530 } 531 532 /** 533 * Returns the thumbnailUri from the avatar URI, or null if avatar URI does not have thumbnail. 534 */ 535 private static Uri getThumbnailUri(final Uri avatarUri) { 536 Uri localUri = null; 537 final String avatarType = AvatarUriUtil.getAvatarType(avatarUri); 538 if (TextUtils.equals(avatarType, AvatarUriUtil.TYPE_LOCAL_RESOURCE_URI)) { 539 localUri = AvatarUriUtil.getPrimaryUri(avatarUri); 540 } else if (UriUtil.isLocalResourceUri(avatarUri)) { 541 localUri = avatarUri; 542 } 543 if (localUri != null && localUri.getAuthority().equals(ContactsContract.AUTHORITY)) { 544 // Contact photos are of the form: content://com.android.contacts/contacts/123/photo 545 final List<String> pathParts = localUri.getPathSegments(); 546 if (pathParts.size() == 3 && 547 pathParts.get(2).equals(Contacts.Photo.CONTENT_DIRECTORY)) { 548 return localUri; 549 } 550 } 551 return null; 552 } 553 554 /** 555 * Returns the displayPhotoUri from the avatar URI, or null if avatar URI 556 * does not have a displayPhotoUri. 557 */ 558 private static Uri getDisplayPhotoUri(final Uri avatarUri) { 559 final Uri thumbnailUri = getThumbnailUri(avatarUri); 560 if (thumbnailUri == null) { 561 return null; 562 } 563 final List<String> originalPaths = thumbnailUri.getPathSegments(); 564 final int originalPathsSize = originalPaths.size(); 565 final StringBuilder newPathBuilder = new StringBuilder(); 566 // Change content://com.android.contacts/contacts("_corp")/123/photo to 567 // content://com.android.contacts/contacts("_corp")/123/display_photo 568 for (int i = 0; i < originalPathsSize; i++) { 569 newPathBuilder.append('/'); 570 if (i == 2) { 571 newPathBuilder.append(ContactsContract.Contacts.Photo.DISPLAY_PHOTO); 572 } else { 573 newPathBuilder.append(originalPaths.get(i)); 574 } 575 } 576 return thumbnailUri.buildUpon().path(newPathBuilder.toString()).build(); 577 } 578 579 private static ImageResource requestContactDisplayPhoto(final Context context, 580 final Uri displayPhotoUri) { 581 final UriImageRequestDescriptor bgDescriptor = 582 new UriImageRequestDescriptor(displayPhotoUri, 583 sWearableImageWidth, 584 sWearableImageHeight, 585 false, /* allowCompression */ 586 true, /* isStatic */ 587 false /* cropToCircle */, 588 ImageUtils.DEFAULT_CIRCLE_BACKGROUND_COLOR /* circleBackgroundColor */, 589 ImageUtils.DEFAULT_CIRCLE_STROKE_COLOR /* circleStrokeColor */); 590 return MediaResourceManager.get().requestMediaResourceSync( 591 bgDescriptor.buildSyncMediaRequest(context)); 592 } 593 594 private static void createMessageNotification(final boolean silent, 595 final String conversationId) { 596 final NotificationState state = MessageNotificationState.getNotificationState(); 597 final boolean softSound = DataModel.get().isNewMessageObservable(conversationId); 598 if (state == null) { 599 cancel(PendingIntentConstants.SMS_NOTIFICATION_ID); 600 if (softSound && !TextUtils.isEmpty(conversationId)) { 601 final Uri ringtoneUri = getNotificationRingtoneUriForConversationId(conversationId); 602 playObservableConversationNotificationSound(ringtoneUri); 603 } 604 return; 605 } 606 processAndSend(state, silent, softSound); 607 608 // The rest of the logic here is for supporting Android Wear devices, specifically for when 609 // we are notifying about multiple conversations. In that case, the Inbox-style summary 610 // notification (which we already processed above) appears on the phone (as it always has), 611 // but wearables show per-conversation notifications, bundled together in a group. 612 613 // It is valid to replace a notification group with another group with fewer conversations, 614 // or even with one notification for a single conversation. In either case, we need to 615 // explicitly cancel any children from the old group which are not being notified about now. 616 final Context context = Factory.get().getApplicationContext(); 617 final ConversationIdSet oldGroupChildIds = getGroupChildIds(context); 618 if (oldGroupChildIds != null && oldGroupChildIds.size() > 0) { 619 cancelStaleGroupChildren(oldGroupChildIds, state); 620 } 621 622 // Send per-conversation notifications (if there are multiple conversations). 623 final ConversationIdSet groupChildIds = new ConversationIdSet(); 624 if (state instanceof MultiConversationNotificationState) { 625 for (final NotificationState child : 626 ((MultiConversationNotificationState) state).mChildren) { 627 processAndSend(child, true /* silent */, softSound); 628 if (child.mConversationIds != null) { 629 groupChildIds.add(child.mConversationIds.first()); 630 } 631 } 632 } 633 634 // Record the new set of group children. 635 writeGroupChildIds(context, groupChildIds); 636 } 637 638 private static void updateBuilderAudioVibrate(final NotificationState state, 639 final NotificationCompat.Builder notifBuilder, final boolean silent, 640 final Uri ringtoneUri, final String conversationId) { 641 int defaults = Notification.DEFAULT_LIGHTS; 642 if (!silent) { 643 final BuglePrefs prefs = Factory.get().getApplicationPrefs(); 644 final long latestNotificationTimestamp = prefs.getLong( 645 BuglePrefsKeys.LATEST_NOTIFICATION_MESSAGE_TIMESTAMP, Long.MIN_VALUE); 646 final long latestReceivedTimestamp = state.getLatestReceivedTimestamp(); 647 prefs.putLong( 648 BuglePrefsKeys.LATEST_NOTIFICATION_MESSAGE_TIMESTAMP, 649 Math.max(latestNotificationTimestamp, latestReceivedTimestamp)); 650 if (latestReceivedTimestamp > latestNotificationTimestamp) { 651 synchronized (mLock) { 652 // Find out the last time we dinged for this conversation 653 Long lastTime = sLastMessageDingTime.get(conversationId); 654 if (sTimeBetweenDingsMs == 0) { 655 sTimeBetweenDingsMs = BugleGservices.get().getInt( 656 BugleGservicesKeys.NOTIFICATION_TIME_BETWEEN_RINGS_SECONDS, 657 BugleGservicesKeys.NOTIFICATION_TIME_BETWEEN_RINGS_SECONDS_DEFAULT) * 658 1000; 659 } 660 if (lastTime == null 661 || SystemClock.elapsedRealtime() - lastTime > sTimeBetweenDingsMs) { 662 sLastMessageDingTime.put(conversationId, SystemClock.elapsedRealtime()); 663 notifBuilder.setSound(ringtoneUri); 664 if (shouldVibrate(state)) { 665 defaults |= Notification.DEFAULT_VIBRATE; 666 } 667 } 668 } 669 } 670 } 671 notifBuilder.setDefaults(defaults); 672 } 673 674 // TODO: this doesn't seem to be defined in NotificationCompat yet. Temporarily 675 // define it here until it makes its way from Notification -> NotificationCompat. 676 /** 677 * Notification category: incoming direct message (SMS, instant message, etc.). 678 */ 679 private static final String CATEGORY_MESSAGE = "msg"; 680 681 private static void sendNotification(final NotificationState notificationState, 682 final Bitmap avatarIcon, final Bitmap avatarHiRes) { 683 final Context context = Factory.get().getApplicationContext(); 684 if (notificationState.mCanceled) { 685 if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { 686 LogUtil.d(TAG, "sendNotification: Notification already cancelled; dropping it"); 687 } 688 return; 689 } 690 691 synchronized (sPendingNotifications) { 692 if (sPendingNotifications.contains(notificationState)) { 693 sPendingNotifications.remove(notificationState); 694 } 695 } 696 697 notificationState.mNotificationBuilder 698 .setSmallIcon(notificationState.getIcon()) 699 .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) 700 .setColor(context.getResources().getColor(R.color.notification_accent_color)) 701// .setPublicVersion(null) // TODO: when/if we ever support different 702 // text on the lockscreen, instead of "contents hidden" 703 .setCategory(CATEGORY_MESSAGE); 704 705 if (avatarIcon != null) { 706 notificationState.mNotificationBuilder.setLargeIcon(avatarIcon); 707 } 708 709 if (notificationState.mParticipantContactUris != null && 710 notificationState.mParticipantContactUris.size() > 0) { 711 for (final Uri contactUri : notificationState.mParticipantContactUris) { 712 notificationState.mNotificationBuilder.addPerson(contactUri.toString()); 713 } 714 } 715 716 final Uri attachmentUri = notificationState.getAttachmentUri(); 717 final String attachmentType = notificationState.getAttachmentType(); 718 Bitmap attachmentBitmap = null; 719 720 // For messages with photo/video attachment, request an image to show in the notification. 721 if (attachmentUri != null && notificationState.mNotificationStyle != null && 722 (notificationState.mNotificationStyle instanceof 723 NotificationCompat.BigPictureStyle) && 724 (ContentType.isImageType(attachmentType) || 725 ContentType.isVideoType(attachmentType))) { 726 final boolean isVideo = ContentType.isVideoType(attachmentType); 727 728 MediaRequest<ImageResource> imageRequest; 729 if (isVideo) { 730 Assert.isTrue(VideoThumbnailRequest.shouldShowIncomingVideoThumbnails()); 731 final MessagePartVideoThumbnailRequestDescriptor videoDescriptor = 732 new MessagePartVideoThumbnailRequestDescriptor(attachmentUri); 733 imageRequest = videoDescriptor.buildSyncMediaRequest(context); 734 } else { 735 final UriImageRequestDescriptor imageDescriptor = 736 new UriImageRequestDescriptor(attachmentUri, 737 sWearableImageWidth, 738 sWearableImageHeight, 739 false /* allowCompression */, 740 true /* isStatic */, 741 false /* cropToCircle */, 742 ImageUtils.DEFAULT_CIRCLE_BACKGROUND_COLOR /* circleBackgroundColor */, 743 ImageUtils.DEFAULT_CIRCLE_STROKE_COLOR /* circleStrokeColor */); 744 imageRequest = imageDescriptor.buildSyncMediaRequest(context); 745 } 746 final ImageResource imageResource = 747 MediaResourceManager.get().requestMediaResourceSync(imageRequest); 748 if (imageResource != null) { 749 try { 750 // Copy the bitmap, because the one in the ImageResource is managed by 751 // MediaResourceManager. 752 Bitmap imageResourceBitmap = imageResource.getBitmap(); 753 Config config = imageResourceBitmap.getConfig(); 754 755 // Make sure our bitmap has a valid format. 756 if (config == null) { 757 config = Bitmap.Config.ARGB_8888; 758 } 759 attachmentBitmap = imageResourceBitmap.copy(config, true); 760 } finally { 761 imageResource.release(); 762 } 763 } 764 } 765 766 fireOffNotification(notificationState, attachmentBitmap, avatarIcon, avatarHiRes); 767 } 768 769 private static void fireOffNotification(final NotificationState notificationState, 770 final Bitmap attachmentBitmap, final Bitmap avatarBitmap, Bitmap avatarHiResBitmap) { 771 if (notificationState.mCanceled) { 772 if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { 773 LogUtil.v(TAG, "Firing off notification, but notification already canceled"); 774 } 775 return; 776 } 777 778 final Context context = Factory.get().getApplicationContext(); 779 780 if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { 781 LogUtil.v(TAG, "MMS picture loaded, bitmap: " + attachmentBitmap); 782 } 783 784 final NotificationCompat.Builder notifBuilder = notificationState.mNotificationBuilder; 785 notifBuilder.setStyle(notificationState.mNotificationStyle); 786 notifBuilder.setColor(context.getResources().getColor(R.color.notification_accent_color)); 787 788 final WearableExtender wearableExtender = new WearableExtender(); 789 setWearableGroupOptions(notifBuilder, notificationState); 790 791 if (avatarHiResBitmap != null) { 792 wearableExtender.setBackground(avatarHiResBitmap); 793 } else if (avatarBitmap != null) { 794 // Nothing to do here; we already set avatarBitmap as the notification icon 795 } else { 796 final Bitmap defaultBackground = BitmapFactory.decodeResource( 797 context.getResources(), R.drawable.bg_sms); 798 wearableExtender.setBackground(defaultBackground); 799 } 800 801 if (notificationState instanceof MultiMessageNotificationState) { 802 if (attachmentBitmap != null) { 803 // When we've got a picture attachment, we do some switcheroo trickery. When 804 // the notification is expanded, we show the picture as a bigPicture. The small 805 // icon shows the sender's avatar. When that same notification is collapsed, the 806 // picture is shown in the location where the avatar is normally shown. The lines 807 // below make all that happen. 808 809 // Here we're taking the picture attachment and making a small, scaled, center 810 // cropped version of the picture we can stuff into the place where the avatar 811 // goes when the notification is collapsed. 812 final Bitmap smallBitmap = ImageUtils.scaleCenterCrop(attachmentBitmap, sIconWidth, 813 sIconHeight); 814 ((NotificationCompat.BigPictureStyle) notificationState.mNotificationStyle) 815 .bigPicture(attachmentBitmap) 816 .bigLargeIcon(avatarBitmap); 817 notificationState.mNotificationBuilder.setLargeIcon(smallBitmap); 818 819 // Add a wearable page with no visible card so you can more easily see the photo. 820 final NotificationCompat.Builder photoPageNotifBuilder = 821 new NotificationCompat.Builder(Factory.get().getApplicationContext()); 822 final WearableExtender photoPageWearableExtender = new WearableExtender(); 823 photoPageWearableExtender.setHintShowBackgroundOnly(true); 824 if (attachmentBitmap != null) { 825 final Bitmap wearBitmap = ImageUtils.scaleCenterCrop(attachmentBitmap, 826 sWearableImageWidth, sWearableImageHeight); 827 photoPageWearableExtender.setBackground(wearBitmap); 828 } 829 photoPageNotifBuilder.extend(photoPageWearableExtender); 830 wearableExtender.addPage(photoPageNotifBuilder.build()); 831 } 832 833 maybeAddWearableConversationLog(wearableExtender, 834 (MultiMessageNotificationState) notificationState); 835 addDownloadMmsAction(notifBuilder, wearableExtender, notificationState); 836 addWearableVoiceReplyAction(wearableExtender, notificationState); 837 } 838 839 // Apply the wearable options and build & post the notification 840 notifBuilder.extend(wearableExtender); 841 doNotify(notifBuilder.build(), notificationState); 842 } 843 844 private static void setWearableGroupOptions(final NotificationCompat.Builder notifBuilder, 845 final NotificationState notificationState) { 846 final String groupKey = "groupkey"; 847 if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { 848 LogUtil.v(TAG, "Group key (for wearables)=" + groupKey); 849 } 850 if (notificationState instanceof MultiConversationNotificationState) { 851 notifBuilder.setGroup(groupKey).setGroupSummary(true); 852 } else if (notificationState instanceof BundledMessageNotificationState) { 853 final int order = ((BundledMessageNotificationState) notificationState).mGroupOrder; 854 // Convert the order to a zero-padded string ("00", "01", "02", etc). 855 // The Wear library orders notifications within a bundle lexicographically 856 // by the sort key, hence the need for zeroes to preserve the ordering. 857 final String sortKey = String.format(Locale.US, "%02d", order); 858 notifBuilder.setGroup(groupKey).setSortKey(sortKey); 859 } 860 } 861 862 private static void maybeAddWearableConversationLog( 863 final WearableExtender wearableExtender, 864 final MultiMessageNotificationState notificationState) { 865 if (!isWearCompanionAppInstalled()) { 866 return; 867 } 868 final String convId = notificationState.mConversationIds.first(); 869 ConversationLineInfo convInfo = notificationState.mConvList.mConvInfos.get(0); 870 final Notification page = MessageNotificationState.buildConversationPageForWearable( 871 convId, 872 convInfo.mParticipantCount); 873 if (page != null) { 874 wearableExtender.addPage(page); 875 } 876 } 877 878 private static void addWearableVoiceReplyAction( 879 final WearableExtender wearableExtender, final NotificationState notificationState) { 880 if (!(notificationState instanceof MultiMessageNotificationState)) { 881 return; 882 } 883 final MultiMessageNotificationState multiMessageNotificationState = 884 (MultiMessageNotificationState) notificationState; 885 final Context context = Factory.get().getApplicationContext(); 886 887 final String conversationId = notificationState.mConversationIds.first(); 888 final ConversationLineInfo convInfo = 889 multiMessageNotificationState.mConvList.mConvInfos.get(0); 890 final String selfId = convInfo.mSelfParticipantId; 891 892 final boolean requiresMms = 893 MmsSmsUtils.getRequireMmsForEmailAddress( 894 convInfo.mIncludeEmailAddress, convInfo.mSubId) || 895 (convInfo.mIsGroup && MmsUtils.groupMmsEnabled(convInfo.mSubId)); 896 897 final int requestCode = multiMessageNotificationState.getReplyIntentRequestCode(); 898 final PendingIntent replyPendingIntent = UIIntents.get() 899 .getPendingIntentForSendingMessageToConversation(context, 900 conversationId, selfId, requiresMms, requestCode); 901 902 final int replyLabelRes = requiresMms ? R.string.notification_reply_via_mms : 903 R.string.notification_reply_via_sms; 904 905 final NotificationCompat.Action.Builder actionBuilder = 906 new NotificationCompat.Action.Builder(R.drawable.ic_wear_reply, 907 context.getString(replyLabelRes), replyPendingIntent); 908 final String[] choices = context.getResources().getStringArray( 909 R.array.notification_reply_choices); 910 final RemoteInput remoteInput = new RemoteInput.Builder(Intent.EXTRA_TEXT).setLabel( 911 context.getString(R.string.notification_reply_prompt)). 912 setChoices(choices) 913 .build(); 914 actionBuilder.addRemoteInput(remoteInput); 915 wearableExtender.addAction(actionBuilder.build()); 916 } 917 918 private static void addDownloadMmsAction(final NotificationCompat.Builder notifBuilder, 919 final WearableExtender wearableExtender, final NotificationState notificationState) { 920 if (!(notificationState instanceof MultiMessageNotificationState)) { 921 return; 922 } 923 final MultiMessageNotificationState multiMessageNotificationState = 924 (MultiMessageNotificationState) notificationState; 925 final ConversationLineInfo convInfo = 926 multiMessageNotificationState.mConvList.mConvInfos.get(0); 927 if (!convInfo.getDoesLatestMessageNeedDownload()) { 928 return; 929 } 930 final String messageId = convInfo.getLatestMessageId(); 931 if (messageId == null) { 932 // No message Id, no download for you 933 return; 934 } 935 final Context context = Factory.get().getApplicationContext(); 936 final PendingIntent downloadPendingIntent = 937 RedownloadMmsAction.getPendingIntentForRedownloadMms(context, messageId); 938 939 final NotificationCompat.Action.Builder actionBuilder = 940 new NotificationCompat.Action.Builder(R.drawable.ic_file_download_light, 941 context.getString(R.string.notification_download_mms), 942 downloadPendingIntent); 943 final NotificationCompat.Action downloadAction = actionBuilder.build(); 944 notifBuilder.addAction(downloadAction); 945 946 // Support the action on a wearable device as well 947 wearableExtender.addAction(downloadAction); 948 } 949 950 private static synchronized void doNotify(final Notification notification, 951 final NotificationState notificationState) { 952 if (notification == null) { 953 return; 954 } 955 final int type = notificationState.mType; 956 final ConversationIdSet conversationIds = notificationState.mConversationIds; 957 final boolean isBundledNotification = 958 (notificationState instanceof BundledMessageNotificationState); 959 960 // Mark the notification as finished 961 notificationState.mCanceled = true; 962 963 final NotificationManagerCompat notificationManager = 964 NotificationManagerCompat.from(Factory.get().getApplicationContext()); 965 // Only need conversationId for tags with a single conversation. 966 String conversationId = null; 967 if (conversationIds != null && conversationIds.size() == 1) { 968 conversationId = conversationIds.first(); 969 } 970 final String notificationTag = buildNotificationTag(type, 971 conversationId, isBundledNotification); 972 973 notification.flags |= Notification.FLAG_AUTO_CANCEL; 974 notification.defaults |= Notification.DEFAULT_LIGHTS; 975 976 notificationManager.notify(notificationTag, type, notification); 977 978 LogUtil.i(TAG, "Notifying for conversation " + conversationId + "; " 979 + "tag = " + notificationTag + ", type = " + type); 980 } 981 982 // This is the message string used in each line of an inboxStyle notification. 983 // TODO: add attachment type 984 static CharSequence formatInboxMessage(final String sender, 985 final CharSequence message, final Uri attachmentUri, final String attachmentType) { 986 final Context context = Factory.get().getApplicationContext(); 987 final TextAppearanceSpan notificationSenderSpan = new TextAppearanceSpan( 988 context, R.style.NotificationSenderText); 989 990 final TextAppearanceSpan notificationTertiaryText = new TextAppearanceSpan( 991 context, R.style.NotificationTertiaryText); 992 993 final SpannableStringBuilder spannableStringBuilder = new SpannableStringBuilder(); 994 if (!TextUtils.isEmpty(sender)) { 995 spannableStringBuilder.append(sender); 996 spannableStringBuilder.setSpan(notificationSenderSpan, 0, sender.length(), 0); 997 } 998 final String separator = context.getString(R.string.notification_separator); 999 1000 if (!TextUtils.isEmpty(message)) { 1001 if (spannableStringBuilder.length() > 0) { 1002 spannableStringBuilder.append(separator); 1003 } 1004 final int start = spannableStringBuilder.length(); 1005 spannableStringBuilder.append(message); 1006 spannableStringBuilder.setSpan(notificationTertiaryText, start, 1007 start + message.length(), 0); 1008 } 1009 if (attachmentUri != null) { 1010 if (spannableStringBuilder.length() > 0) { 1011 spannableStringBuilder.append(separator); 1012 } 1013 spannableStringBuilder.append(formatAttachmentTag(null, attachmentType)); 1014 } 1015 return spannableStringBuilder; 1016 } 1017 1018 protected static CharSequence buildColonSeparatedMessage( 1019 final String title, final CharSequence content, final Uri attachmentUri, 1020 final String attachmentType) { 1021 return buildBoldedMessage(title, content, attachmentUri, attachmentType, 1022 R.string.notification_ticker_separator); 1023 } 1024 1025 protected static CharSequence buildSpaceSeparatedMessage( 1026 final String title, final CharSequence content, final Uri attachmentUri, 1027 final String attachmentType) { 1028 return buildBoldedMessage(title, content, attachmentUri, attachmentType, 1029 R.string.notification_space_separator); 1030 } 1031 1032 /** 1033 * buildBoldedMessage - build a formatted message where the title is bold, there's a 1034 * separator, then the message. 1035 */ 1036 private static CharSequence buildBoldedMessage( 1037 final String title, final CharSequence message, final Uri attachmentUri, 1038 final String attachmentType, 1039 final int separatorId) { 1040 final Context context = Factory.get().getApplicationContext(); 1041 final SpannableStringBuilder spanBuilder = new SpannableStringBuilder(); 1042 1043 // Boldify the title (which is the sender's name) 1044 if (!TextUtils.isEmpty(title)) { 1045 spanBuilder.append(title); 1046 spanBuilder.setSpan(new StyleSpan(Typeface.BOLD), 0, title.length(), 1047 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 1048 } 1049 if (!TextUtils.isEmpty(message)) { 1050 if (spanBuilder.length() > 0) { 1051 spanBuilder.append(context.getString(separatorId)); 1052 } 1053 spanBuilder.append(message); 1054 } 1055 if (attachmentUri != null) { 1056 if (spanBuilder.length() > 0) { 1057 final String separator = context.getString(R.string.notification_separator); 1058 spanBuilder.append(separator); 1059 } 1060 spanBuilder.append(formatAttachmentTag(null, attachmentType)); 1061 } 1062 return spanBuilder; 1063 } 1064 1065 static CharSequence formatAttachmentTag(final String author, final String attachmentType) { 1066 final Context context = Factory.get().getApplicationContext(); 1067 final TextAppearanceSpan notificationSecondaryText = new TextAppearanceSpan( 1068 context, R.style.NotificationSecondaryText); 1069 final SpannableStringBuilder spannableStringBuilder = new SpannableStringBuilder(); 1070 if (!TextUtils.isEmpty(author)) { 1071 final TextAppearanceSpan notificationSenderSpan = new TextAppearanceSpan( 1072 context, R.style.NotificationSenderText); 1073 spannableStringBuilder.append(author); 1074 spannableStringBuilder.setSpan(notificationSenderSpan, 0, author.length(), 0); 1075 final String separator = context.getString(R.string.notification_separator); 1076 spannableStringBuilder.append(separator); 1077 } 1078 final int start = spannableStringBuilder.length(); 1079 // The default attachment type is an image, since that's what was originally 1080 // supported. When there's no content type, assume it's an image. 1081 int message = R.string.notification_picture; 1082 if (ContentType.isAudioType(attachmentType)) { 1083 message = R.string.notification_audio; 1084 } else if (ContentType.isVideoType(attachmentType)) { 1085 message = R.string.notification_video; 1086 } else if (ContentType.isVCardType(attachmentType)) { 1087 message = R.string.notification_vcard; 1088 } 1089 spannableStringBuilder.append(context.getText(message)); 1090 spannableStringBuilder.setSpan(notificationSecondaryText, start, 1091 spannableStringBuilder.length(), 0); 1092 return spannableStringBuilder; 1093 } 1094 1095 /** 1096 * Play the observable conversation notification sound (it's the regular notification sound, but 1097 * played at half-volume) 1098 */ 1099 private static void playObservableConversationNotificationSound(final Uri ringtoneUri) { 1100 final Context context = Factory.get().getApplicationContext(); 1101 final AudioManager audioManager = (AudioManager) context 1102 .getSystemService(Context.AUDIO_SERVICE); 1103 final boolean silenced = 1104 audioManager.getRingerMode() != AudioManager.RINGER_MODE_NORMAL; 1105 if (silenced) { 1106 return; 1107 } 1108 1109 final NotificationPlayer player = new NotificationPlayer(LogUtil.BUGLE_TAG); 1110 player.play(ringtoneUri, false, 1111 AudioManager.STREAM_NOTIFICATION, 1112 OBSERVABLE_CONVERSATION_NOTIFICATION_VOLUME); 1113 1114 // Stop the sound after five seconds to handle continuous ringtones 1115 ThreadUtil.getMainThreadHandler().postDelayed(new Runnable() { 1116 @Override 1117 public void run() { 1118 player.stop(); 1119 } 1120 }, 5000); 1121 } 1122 1123 public static boolean isWearCompanionAppInstalled() { 1124 boolean found = false; 1125 try { 1126 Factory.get().getApplicationContext().getPackageManager() 1127 .getPackageInfo(WEARABLE_COMPANION_APP_PACKAGE, 0); 1128 found = true; 1129 } catch (final NameNotFoundException e) { 1130 // Ignore; found is already false 1131 } 1132 return found; 1133 } 1134 1135 /** 1136 * When we go to the conversation list, call this to mark all messages as seen. That means 1137 * we won't show a notification again for the same message. 1138 */ 1139 public static void markAllMessagesAsSeen() { 1140 MarkAsSeenAction.markAllAsSeen(); 1141 resetLastMessageDing(null); // reset the ding timeout for all conversations 1142 } 1143 1144 /** 1145 * When we open a particular conversation, call this to mark all messages as read. 1146 */ 1147 public static void markMessagesAsRead(final String conversationId) { 1148 MarkAsReadAction.markAsRead(conversationId); 1149 resetLastMessageDing(conversationId); 1150 } 1151 1152 /** 1153 * Returns the conversation ids of all active, grouped notifications, or 1154 * {code null} if no notifications are currently active and grouped. 1155 */ 1156 private static ConversationIdSet getGroupChildIds(final Context context) { 1157 final String prefKey = context.getString(R.string.notifications_group_children_key); 1158 final String groupChildIdsText = BuglePrefs.getApplicationPrefs().getString(prefKey, ""); 1159 if (!TextUtils.isEmpty(groupChildIdsText)) { 1160 return ConversationIdSet.createSet(groupChildIdsText); 1161 } else { 1162 return null; 1163 } 1164 } 1165 1166 /** 1167 * Records the conversation ids of the currently active grouped notifications. 1168 */ 1169 private static void writeGroupChildIds(final Context context, 1170 final ConversationIdSet childIds) { 1171 final ConversationIdSet oldChildIds = getGroupChildIds(context); 1172 if (childIds.equals(oldChildIds)) { 1173 return; 1174 } 1175 final String prefKey = context.getString(R.string.notifications_group_children_key); 1176 BuglePrefs.getApplicationPrefs().putString(prefKey, childIds.getDelimitedString()); 1177 } 1178 1179 /** 1180 * Reset the timer for a notification ding on a particular conversation or all conversations. 1181 */ 1182 public static void resetLastMessageDing(final String conversationId) { 1183 synchronized (mLock) { 1184 if (TextUtils.isEmpty(conversationId)) { 1185 // reset all conversation dings 1186 sLastMessageDingTime.clear(); 1187 } else { 1188 sLastMessageDingTime.remove(conversationId); 1189 } 1190 } 1191 } 1192 1193 public static void notifyEmergencySmsFailed(final String emergencyNumber, 1194 final String conversationId) { 1195 final Context context = Factory.get().getApplicationContext(); 1196 1197 final CharSequence line1 = MessageNotificationState.applyWarningTextColor(context, 1198 context.getString(R.string.notification_emergency_send_failure_line1, 1199 emergencyNumber)); 1200 final String line2 = context.getString(R.string.notification_emergency_send_failure_line2, 1201 emergencyNumber); 1202 final PendingIntent destinationIntent = UIIntents.get() 1203 .getPendingIntentForConversationActivity(context, conversationId, null /* draft */); 1204 1205 final NotificationCompat.Builder builder = new NotificationCompat.Builder(context); 1206 builder.setTicker(line1) 1207 .setContentTitle(line1) 1208 .setContentText(line2) 1209 .setStyle(new NotificationCompat.BigTextStyle(builder).bigText(line2)) 1210 .setSmallIcon(R.drawable.ic_failed_light) 1211 .setContentIntent(destinationIntent) 1212 .setSound(UriUtil.getUriForResourceId(context, R.raw.message_failure)); 1213 1214 final String tag = context.getPackageName() + ":emergency_sms_error"; 1215 NotificationManagerCompat.from(context).notify( 1216 tag, 1217 PendingIntentConstants.MSG_SEND_ERROR, 1218 builder.build()); 1219 } 1220} 1221 1222