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 17 package com.android.messaging.datamodel; 18 19 import android.app.Notification; 20 import android.app.PendingIntent; 21 import android.content.Context; 22 import android.content.Intent; 23 import android.content.pm.PackageManager.NameNotFoundException; 24 import android.content.res.Resources; 25 import android.graphics.Bitmap; 26 import android.graphics.Bitmap.Config; 27 import android.graphics.BitmapFactory; 28 import android.graphics.Typeface; 29 import android.media.AudioManager; 30 import android.net.Uri; 31 import android.os.Bundle; 32 import android.os.SystemClock; 33 import android.provider.ContactsContract; 34 import android.provider.ContactsContract.Contacts; 35 import androidx.core.app.NotificationCompat; 36 import androidx.core.app.NotificationCompat.WearableExtender; 37 import androidx.core.app.NotificationManagerCompat; 38 import androidx.core.app.RemoteInput; 39 import androidx.collection.SimpleArrayMap; 40 import android.text.Spannable; 41 import android.text.SpannableStringBuilder; 42 import android.text.TextUtils; 43 import android.text.style.StyleSpan; 44 import android.text.style.TextAppearanceSpan; 45 46 import com.android.messaging.Factory; 47 import com.android.messaging.R; 48 import com.android.messaging.datamodel.MessageNotificationState.BundledMessageNotificationState; 49 import com.android.messaging.datamodel.MessageNotificationState.ConversationLineInfo; 50 import com.android.messaging.datamodel.MessageNotificationState.MultiConversationNotificationState; 51 import com.android.messaging.datamodel.MessageNotificationState.MultiMessageNotificationState; 52 import com.android.messaging.datamodel.action.MarkAsReadAction; 53 import com.android.messaging.datamodel.action.MarkAsSeenAction; 54 import com.android.messaging.datamodel.action.RedownloadMmsAction; 55 import com.android.messaging.datamodel.data.ConversationListItemData; 56 import com.android.messaging.datamodel.media.AvatarRequestDescriptor; 57 import com.android.messaging.datamodel.media.ImageResource; 58 import com.android.messaging.datamodel.media.MediaRequest; 59 import com.android.messaging.datamodel.media.MediaResourceManager; 60 import com.android.messaging.datamodel.media.MessagePartVideoThumbnailRequestDescriptor; 61 import com.android.messaging.datamodel.media.UriImageRequestDescriptor; 62 import com.android.messaging.datamodel.media.VideoThumbnailRequest; 63 import com.android.messaging.sms.MmsSmsUtils; 64 import com.android.messaging.sms.MmsUtils; 65 import com.android.messaging.ui.UIIntents; 66 import com.android.messaging.util.Assert; 67 import com.android.messaging.util.AvatarUriUtil; 68 import com.android.messaging.util.BugleGservices; 69 import com.android.messaging.util.BugleGservicesKeys; 70 import com.android.messaging.util.BuglePrefs; 71 import com.android.messaging.util.BuglePrefsKeys; 72 import com.android.messaging.util.ContentType; 73 import com.android.messaging.util.ConversationIdSet; 74 import com.android.messaging.util.ImageUtils; 75 import com.android.messaging.util.LogUtil; 76 import com.android.messaging.util.NotificationPlayer; 77 import com.android.messaging.util.OsUtil; 78 import com.android.messaging.util.PendingIntentConstants; 79 import com.android.messaging.util.PhoneUtils; 80 import com.android.messaging.util.RingtoneUtil; 81 import com.android.messaging.util.ThreadUtil; 82 import com.android.messaging.util.UriUtil; 83 84 import java.util.HashSet; 85 import java.util.Iterator; 86 import java.util.List; 87 import java.util.Locale; 88 import 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 */ 103 public 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 */ update(final boolean silent, final int coverage)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 */ update(final boolean silent, final String conversationId, final int coverage)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 */ cancel(final int type)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 */ cancel(final int type, final String conversationId, final boolean isBundledNotification)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 */ cancelStaleGroupChildren(final ConversationIdSet previousGroupChildren, final NotificationState state)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 */ shouldNotify()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 */ shouldVibrate(final NotificationState state)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 getNotificationRingtoneUriForConversationId(final String conversationId)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 */ buildNotificationTag(final String name, final String conversationId)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 */ buildNotificationTag(final int type, final String conversationId)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 */ buildNotificationTag(final int type, final String conversationId, final boolean bundledNotification)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 checkInitialized()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 processAndSend(final NotificationState state, final boolean silent, final boolean softSound)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 */ getThumbnailUri(final Uri avatarUri)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 */ getDisplayPhotoUri(final Uri avatarUri)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 requestContactDisplayPhoto(final Context context, final Uri displayPhotoUri)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 createMessageNotification(final boolean silent, final String conversationId)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 updateBuilderAudioVibrate(final NotificationState state, final NotificationCompat.Builder notifBuilder, final boolean silent, final Uri ringtoneUri, final String conversationId)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 sendNotification(final NotificationState notificationState, final Bitmap avatarIcon, final Bitmap avatarHiRes)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 fireOffNotification(final NotificationState notificationState, final Bitmap attachmentBitmap, final Bitmap avatarBitmap, Bitmap avatarHiResBitmap)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 setWearableGroupOptions(final NotificationCompat.Builder notifBuilder, final NotificationState notificationState)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 maybeAddWearableConversationLog( final WearableExtender wearableExtender, final MultiMessageNotificationState notificationState)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 addWearableVoiceReplyAction( final WearableExtender wearableExtender, final NotificationState notificationState)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 addDownloadMmsAction(final NotificationCompat.Builder notifBuilder, final WearableExtender wearableExtender, final NotificationState notificationState)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 doNotify(final Notification notification, final NotificationState notificationState)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 formatInboxMessage(final String sender, final CharSequence message, final Uri attachmentUri, final String attachmentType)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 buildColonSeparatedMessage( final String title, final CharSequence content, final Uri attachmentUri, final String attachmentType)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 buildSpaceSeparatedMessage( final String title, final CharSequence content, final Uri attachmentUri, final String attachmentType)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 */ buildBoldedMessage( final String title, final CharSequence message, final Uri attachmentUri, final String attachmentType, final int separatorId)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 formatAttachmentTag(final String author, final String attachmentType)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 */ playObservableConversationNotificationSound(final Uri ringtoneUri)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 isWearCompanionAppInstalled()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 */ markAllMessagesAsSeen()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 */ markMessagesAsRead(final String conversationId)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 */ getGroupChildIds(final Context context)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 */ writeGroupChildIds(final Context context, final ConversationIdSet childIds)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 */ resetLastMessageDing(final String conversationId)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 notifyEmergencySmsFailed(final String emergencyNumber, final String conversationId)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