summaryrefslogtreecommitdiffstats
path: root/src/com/android/messaging/datamodel/BugleNotifications.java
diff options
context:
space:
mode:
authorMike Dodd <mdodd@google.com>2015-08-11 11:16:59 -0700
committerMike Dodd <mdodd@google.com>2015-08-12 08:58:28 -0700
commit461a34b466cb4b13dbbc2ec6330b31e217b2ac4e (patch)
treebc4b489af52d0e2521e21167d2ad76a47256f348 /src/com/android/messaging/datamodel/BugleNotifications.java
parent8b3e2b9c1b0a09423a7ba5d1091b9192106502f8 (diff)
downloadandroid_packages_apps_Messaging-461a34b466cb4b13dbbc2ec6330b31e217b2ac4e.tar.gz
android_packages_apps_Messaging-461a34b466cb4b13dbbc2ec6330b31e217b2ac4e.tar.bz2
android_packages_apps_Messaging-461a34b466cb4b13dbbc2ec6330b31e217b2ac4e.zip
Initial checkin of AOSP Messaging app.
b/23110861 Change-Id: I9aa980d7569247d6b2ca78f5dcb4502e1eaadb8a
Diffstat (limited to 'src/com/android/messaging/datamodel/BugleNotifications.java')
-rw-r--r--src/com/android/messaging/datamodel/BugleNotifications.java1221
1 files changed, 1221 insertions, 0 deletions
diff --git a/src/com/android/messaging/datamodel/BugleNotifications.java b/src/com/android/messaging/datamodel/BugleNotifications.java
new file mode 100644
index 0000000..b796e73
--- /dev/null
+++ b/src/com/android/messaging/datamodel/BugleNotifications.java
@@ -0,0 +1,1221 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.messaging.datamodel;
+
+import android.app.Notification;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager.NameNotFoundException;
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.Bitmap.Config;
+import android.graphics.BitmapFactory;
+import android.graphics.Typeface;
+import android.media.AudioManager;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.SystemClock;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.Contacts;
+import android.support.v4.app.NotificationCompat;
+import android.support.v4.app.NotificationCompat.WearableExtender;
+import android.support.v4.app.NotificationManagerCompat;
+import android.support.v4.app.RemoteInput;
+import android.support.v4.util.SimpleArrayMap;
+import android.text.Spannable;
+import android.text.SpannableStringBuilder;
+import android.text.TextUtils;
+import android.text.style.StyleSpan;
+import android.text.style.TextAppearanceSpan;
+
+import com.android.messaging.Factory;
+import com.android.messaging.R;
+import com.android.messaging.datamodel.MessageNotificationState.BundledMessageNotificationState;
+import com.android.messaging.datamodel.MessageNotificationState.ConversationLineInfo;
+import com.android.messaging.datamodel.MessageNotificationState.MultiConversationNotificationState;
+import com.android.messaging.datamodel.MessageNotificationState.MultiMessageNotificationState;
+import com.android.messaging.datamodel.action.MarkAsReadAction;
+import com.android.messaging.datamodel.action.MarkAsSeenAction;
+import com.android.messaging.datamodel.action.RedownloadMmsAction;
+import com.android.messaging.datamodel.data.ConversationListItemData;
+import com.android.messaging.datamodel.media.AvatarRequestDescriptor;
+import com.android.messaging.datamodel.media.ImageResource;
+import com.android.messaging.datamodel.media.MediaRequest;
+import com.android.messaging.datamodel.media.MediaResourceManager;
+import com.android.messaging.datamodel.media.MessagePartVideoThumbnailRequestDescriptor;
+import com.android.messaging.datamodel.media.UriImageRequestDescriptor;
+import com.android.messaging.datamodel.media.VideoThumbnailRequest;
+import com.android.messaging.sms.MmsSmsUtils;
+import com.android.messaging.sms.MmsUtils;
+import com.android.messaging.ui.UIIntents;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.AvatarUriUtil;
+import com.android.messaging.util.BugleGservices;
+import com.android.messaging.util.BugleGservicesKeys;
+import com.android.messaging.util.BuglePrefs;
+import com.android.messaging.util.BuglePrefsKeys;
+import com.android.messaging.util.ContentType;
+import com.android.messaging.util.ConversationIdSet;
+import com.android.messaging.util.ImageUtils;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.NotificationPlayer;
+import com.android.messaging.util.OsUtil;
+import com.android.messaging.util.PendingIntentConstants;
+import com.android.messaging.util.PhoneUtils;
+import com.android.messaging.util.RingtoneUtil;
+import com.android.messaging.util.ThreadUtil;
+import com.android.messaging.util.UriUtil;
+
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+
+/**
+ * Handle posting, updating and removing all conversation notifications.
+ *
+ * There are currently two main classes of notification and their rules: <p>
+ * 1) Messages - {@link MessageNotificationState}. Only one message notification.
+ * Unread messages across senders and conversations are coalesced.<p>
+ * 2) Failed Messages - {@link MessageNotificationState#checkFailedMesages } Only one failed
+ * message. Multiple failures are coalesced.<p>
+ *
+ * To add a new class of notifications, subclass the NotificationState and add commands which
+ * create one and pass into general creation function.
+ *
+ */
+public class BugleNotifications {
+ // Logging
+ public static final String TAG = LogUtil.BUGLE_NOTIFICATIONS_TAG;
+
+ // Constants to use for update.
+ public static final int UPDATE_NONE = 0;
+ public static final int UPDATE_MESSAGES = 1;
+ public static final int UPDATE_ERRORS = 2;
+ public static final int UPDATE_ALL = UPDATE_MESSAGES + UPDATE_ERRORS;
+
+ // Constants for notification type used for audio and vibration settings.
+ public static final int LOCAL_SMS_NOTIFICATION = 0;
+
+ private static final String SMS_NOTIFICATION_TAG = ":sms:";
+ private static final String SMS_ERROR_NOTIFICATION_TAG = ":error:";
+
+ private static final String WEARABLE_COMPANION_APP_PACKAGE = "com.google.android.wearable.app";
+
+ private static final Set<NotificationState> sPendingNotifications =
+ new HashSet<NotificationState>();
+
+ private static int sWearableImageWidth;
+ private static int sWearableImageHeight;
+ private static int sIconWidth;
+ private static int sIconHeight;
+
+ private static boolean sInitialized = false;
+
+ private static final Object mLock = new Object();
+
+ // sLastMessageDingTime is a map between a conversation id and a time. It's used to keep track
+ // of the time we last dinged a message for this conversation. When messages are coming in
+ // at flurry, we don't want to over-ding the user.
+ private static final SimpleArrayMap<String, Long> sLastMessageDingTime =
+ new SimpleArrayMap<String, Long>();
+ private static int sTimeBetweenDingsMs;
+
+ /**
+ * This is the volume at which to play the observable-conversation notification sound,
+ * expressed as a fraction of the system notification volume.
+ */
+ private static final float OBSERVABLE_CONVERSATION_NOTIFICATION_VOLUME = 0.25f;
+
+ /**
+ * Entry point for posting notifications.
+ * Don't call this on the UI thread.
+ * @param silent If true, no ring will be played. If false, checks global settings before
+ * playing a ringtone
+ * @param coverage Indicates which notification types should be checked. Valid values are
+ * UPDATE_NONE, UPDATE_MESSAGES, UPDATE_ERRORS, or UPDATE_ALL
+ */
+ public static void update(final boolean silent, final int coverage) {
+ update(silent, null /* conversationId */, coverage);
+ }
+
+ /**
+ * Entry point for posting notifications.
+ * Don't call this on the UI thread.
+ * @param silent If true, no ring will be played. If false, checks global settings before
+ * playing a ringtone
+ * @param conversationId Conversation ID where a new message was received
+ * @param coverage Indicates which notification types should be checked. Valid values are
+ * UPDATE_NONE, UPDATE_MESSAGES, UPDATE_ERRORS, or UPDATE_ALL
+ */
+ public static void update(final boolean silent, final String conversationId,
+ final int coverage) {
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "Update: silent = " + silent
+ + " conversationId = " + conversationId
+ + " coverage = " + coverage);
+ }
+ Assert.isNotMainThread();
+ checkInitialized();
+
+ if (!shouldNotify()) {
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "Notifications disabled");
+ }
+ cancel(PendingIntentConstants.SMS_NOTIFICATION_ID);
+ return;
+ } else {
+ if ((coverage & UPDATE_MESSAGES) != 0) {
+ createMessageNotification(silent, conversationId);
+ }
+ }
+ if ((coverage & UPDATE_ERRORS) != 0) {
+ MessageNotificationState.checkFailedMessages();
+ }
+ }
+
+ /**
+ * Cancel all notifications of a certain type.
+ *
+ * @param type Message or error notifications from Constants.
+ */
+ private static synchronized void cancel(final int type) {
+ cancel(type, null, false);
+ }
+
+ /**
+ * Cancel all notifications of a certain type.
+ *
+ * @param type Message or error notifications from Constants.
+ * @param conversationId If set, cancel the notification for this
+ * conversation only. For message notifications, this only works
+ * if the notifications are bundled (group children).
+ * @param isBundledNotification True if this notification is part of a
+ * notification bundle. This only applies to message notifications,
+ * which are bundled together with other message notifications.
+ */
+ private static synchronized void cancel(final int type, final String conversationId,
+ final boolean isBundledNotification) {
+ final String notificationTag = buildNotificationTag(type, conversationId,
+ isBundledNotification);
+ final NotificationManagerCompat notificationManager =
+ NotificationManagerCompat.from(Factory.get().getApplicationContext());
+
+ // Find all pending notifications and cancel them.
+ synchronized (sPendingNotifications) {
+ final Iterator<NotificationState> iter = sPendingNotifications.iterator();
+ while (iter.hasNext()) {
+ final NotificationState notifState = iter.next();
+ if (notifState.mType == type) {
+ notifState.mCanceled = true;
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "Canceling pending notification");
+ }
+ iter.remove();
+ }
+ }
+ }
+ notificationManager.cancel(notificationTag, type);
+ if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
+ LogUtil.d(TAG, "Canceled notifications of type " + type);
+ }
+
+ // Message notifications for multiple conversations can be grouped together (see comment in
+ // createMessageNotification). We need to do bookkeeping to track the current set of
+ // notification group children, including removing them when we cancel notifications).
+ if (type == PendingIntentConstants.SMS_NOTIFICATION_ID) {
+ final Context context = Factory.get().getApplicationContext();
+ final ConversationIdSet groupChildIds = getGroupChildIds(context);
+
+ if (groupChildIds != null && groupChildIds.size() > 0) {
+ // If a conversation is specified, remove just that notification. Otherwise,
+ // we're removing the group summary so clear all children.
+ if (conversationId != null) {
+ groupChildIds.remove(conversationId);
+ writeGroupChildIds(context, groupChildIds);
+ } else {
+ cancelStaleGroupChildren(groupChildIds, null);
+ // We'll update the group children preference as we cancel each child,
+ // so we don't need to do it here.
+ }
+ }
+ }
+ }
+
+ /**
+ * Cancels stale notifications from the currently active group of
+ * notifications. If the {@code state} parameter is an instance of
+ * {@link MultiConversationNotificationState} it represents a new
+ * notification group. This method will cancel any notifications that were
+ * in the old group, but not the new one. If the new notification is not a
+ * group, then all existing grouped notifications are cancelled.
+ *
+ * @param previousGroupChildren Conversation ids for the active notification
+ * group
+ * @param state New notification state
+ */
+ private static void cancelStaleGroupChildren(final ConversationIdSet previousGroupChildren,
+ final NotificationState state) {
+ final ConversationIdSet newChildren = new ConversationIdSet();
+ if (state instanceof MultiConversationNotificationState) {
+ for (final NotificationState child :
+ ((MultiConversationNotificationState) state).mChildren) {
+ if (child.mConversationIds != null) {
+ newChildren.add(child.mConversationIds.first());
+ }
+ }
+ }
+ for (final String childConversationId : previousGroupChildren) {
+ if (!newChildren.contains(childConversationId)) {
+ cancel(PendingIntentConstants.SMS_NOTIFICATION_ID, childConversationId, true);
+ }
+ }
+ }
+
+ /**
+ * Returns {@code true} if incoming notifications should display a
+ * notification, {@code false} otherwise.
+ *
+ * @return true if the notification should occur
+ */
+ private static boolean shouldNotify() {
+ // If we're not the default sms app, don't put up any notifications.
+ if (!PhoneUtils.getDefault().isDefaultSmsApp()) {
+ return false;
+ }
+
+ // Now check prefs (i.e. settings) to see if the user turned off notifications.
+ final BuglePrefs prefs = BuglePrefs.getApplicationPrefs();
+ final Context context = Factory.get().getApplicationContext();
+ final String prefKey = context.getString(R.string.notifications_enabled_pref_key);
+ final boolean defaultValue = context.getResources().getBoolean(
+ R.bool.notifications_enabled_pref_default);
+ return prefs.getBoolean(prefKey, defaultValue);
+ }
+
+ /**
+ * Returns {@code true} if incoming notifications for the given {@link NotificationState}
+ * should vibrate the device, {@code false} otherwise.
+ *
+ * @return true if vibration should be used
+ */
+ public static boolean shouldVibrate(final NotificationState state) {
+ // The notification should vibrate if the global setting is turned on AND
+ // the per-conversation setting is turned on (default).
+ if (!state.getNotificationVibrate()) {
+ return false;
+ } else {
+ final BuglePrefs prefs = BuglePrefs.getApplicationPrefs();
+ final Context context = Factory.get().getApplicationContext();
+ final String prefKey = context.getString(R.string.notification_vibration_pref_key);
+ final boolean defaultValue = context.getResources().getBoolean(
+ R.bool.notification_vibration_pref_default);
+ return prefs.getBoolean(prefKey, defaultValue);
+ }
+ }
+
+ private static Uri getNotificationRingtoneUriForConversationId(final String conversationId) {
+ final DatabaseWrapper db = DataModel.get().getDatabase();
+ final ConversationListItemData convData =
+ ConversationListItemData.getExistingConversation(db, conversationId);
+ return RingtoneUtil.getNotificationRingtoneUri(
+ convData != null ? convData.getNotificationSoundUri() : null);
+ }
+
+ /**
+ * Returns a unique tag to identify a notification.
+ *
+ * @param name The tag name (in practice, the type)
+ * @param conversationId The conversation id (optional)
+ */
+ private static String buildNotificationTag(final String name,
+ final String conversationId) {
+ final Context context = Factory.get().getApplicationContext();
+ if (conversationId != null) {
+ return context.getPackageName() + name + ":" + conversationId;
+ } else {
+ return context.getPackageName() + name;
+ }
+ }
+
+ /**
+ * Returns a unique tag to identify a notification.
+ * <p>
+ * This delegates to
+ * {@link #buildNotificationTag(int, String, boolean)} and can be
+ * used when the notification is never bundled (e.g. error notifications).
+ */
+ static String buildNotificationTag(final int type, final String conversationId) {
+ return buildNotificationTag(type, conversationId, false /* bundledNotification */);
+ }
+
+ /**
+ * Returns a unique tag to identify a notification.
+ *
+ * @param type One of the constants in {@link PendingIntentConstants}
+ * @param conversationId The conversation id (where applicable)
+ * @param bundledNotification Set to true if this notification will be
+ * bundled together with other notifications (e.g. on a wearable
+ * device).
+ */
+ static String buildNotificationTag(final int type, final String conversationId,
+ final boolean bundledNotification) {
+ String tag = null;
+ switch(type) {
+ case PendingIntentConstants.SMS_NOTIFICATION_ID:
+ if (bundledNotification) {
+ tag = buildNotificationTag(SMS_NOTIFICATION_TAG, conversationId);
+ } else {
+ tag = buildNotificationTag(SMS_NOTIFICATION_TAG, null);
+ }
+ break;
+ case PendingIntentConstants.MSG_SEND_ERROR:
+ tag = buildNotificationTag(SMS_ERROR_NOTIFICATION_TAG, null);
+ break;
+ }
+ return tag;
+ }
+
+ private static void checkInitialized() {
+ if (!sInitialized) {
+ final Resources resources = Factory.get().getApplicationContext().getResources();
+ sWearableImageWidth = resources.getDimensionPixelSize(
+ R.dimen.notification_wearable_image_width);
+ sWearableImageHeight = resources.getDimensionPixelSize(
+ R.dimen.notification_wearable_image_height);
+ sIconHeight = (int) resources.getDimension(
+ android.R.dimen.notification_large_icon_height);
+ sIconWidth =
+ (int) resources.getDimension(android.R.dimen.notification_large_icon_width);
+
+ sInitialized = true;
+ }
+ }
+
+ private static void processAndSend(final NotificationState state, final boolean silent,
+ final boolean softSound) {
+ final Context context = Factory.get().getApplicationContext();
+ final NotificationCompat.Builder notifBuilder = new NotificationCompat.Builder(context);
+ notifBuilder.setCategory(Notification.CATEGORY_MESSAGE);
+ // TODO: Need to fix this for multi conversation notifications to rate limit dings.
+ final String conversationId = state.mConversationIds.first();
+
+
+ final Uri ringtoneUri = RingtoneUtil.getNotificationRingtoneUri(state.getRingtoneUri());
+ // If the notification's conversation is currently observable (focused or in the
+ // conversation list), then play a notification beep at a low volume and don't display an
+ // actual notification.
+ if (softSound) {
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "processAndSend: fromConversationId == " +
+ "sCurrentlyDisplayedConversationId so NOT showing notification," +
+ " but playing soft sound. conversationId: " + conversationId);
+ }
+ playObservableConversationNotificationSound(ringtoneUri);
+ return;
+ }
+ state.mBaseRequestCode = state.mType;
+
+ // Set the delete intent (except for bundled wearable notifications, which are dismissed
+ // as a group, either from the wearable or when the summary notification is dismissed from
+ // the host device).
+ if (!(state instanceof BundledMessageNotificationState)) {
+ final PendingIntent clearIntent = state.getClearIntent();
+ notifBuilder.setDeleteIntent(clearIntent);
+ }
+
+ updateBuilderAudioVibrate(state, notifBuilder, silent, ringtoneUri, conversationId);
+
+ // Set the content intent
+ PendingIntent destinationIntent;
+ if (state.mConversationIds.size() > 1) {
+ // We have notifications for multiple conversation, go to the conversation list.
+ destinationIntent = UIIntents.get()
+ .getPendingIntentForConversationListActivity(context);
+ } else {
+ // We have a single conversation, go directly to that conversation.
+ destinationIntent = UIIntents.get()
+ .getPendingIntentForConversationActivity(context,
+ state.mConversationIds.first(),
+ null /*draft*/);
+ }
+ notifBuilder.setContentIntent(destinationIntent);
+
+ // TODO: set based on contact coming from a favorite.
+ notifBuilder.setPriority(state.getPriority());
+
+ // Save the state of the notification in-progress so when the avatar is loaded,
+ // we can continue building the notification.
+ final NotificationCompat.Style notifStyle = state.build(notifBuilder);
+ state.mNotificationBuilder = notifBuilder;
+ state.mNotificationStyle = notifStyle;
+ if (!state.mPeople.isEmpty()) {
+ final Bundle people = new Bundle();
+ people.putStringArray(NotificationCompat.EXTRA_PEOPLE,
+ state.mPeople.toArray(new String[state.mPeople.size()]));
+ notifBuilder.addExtras(people);
+ }
+
+ if (state.mParticipantAvatarsUris != null) {
+ final Uri avatarUri = state.mParticipantAvatarsUris.get(0);
+ final AvatarRequestDescriptor descriptor = new AvatarRequestDescriptor(avatarUri,
+ sIconWidth, sIconHeight, OsUtil.isAtLeastL());
+ final MediaRequest<ImageResource> imageRequest = descriptor.buildSyncMediaRequest(
+ context);
+
+ synchronized (sPendingNotifications) {
+ sPendingNotifications.add(state);
+ }
+
+ // Synchronously load the avatar.
+ final ImageResource avatarImage =
+ MediaResourceManager.get().requestMediaResourceSync(imageRequest);
+ if (avatarImage != null) {
+ ImageResource avatarHiRes = null;
+ try {
+ if (isWearCompanionAppInstalled()) {
+ // For Wear users, we need to request a high-res avatar image to use as the
+ // notification card background. If the sender has a contact photo, we'll
+ // request the display photo from the Contacts provider. Otherwise, we ask
+ // the local content provider for a hi-res version of the generic avatar
+ // (e.g. letter with colored background).
+ avatarHiRes = requestContactDisplayPhoto(context,
+ getDisplayPhotoUri(avatarUri));
+ if (avatarHiRes == null) {
+ final AvatarRequestDescriptor hiResDesc =
+ new AvatarRequestDescriptor(avatarUri,
+ sWearableImageWidth,
+ sWearableImageHeight,
+ false /* cropToCircle */,
+ true /* isWearBackground */);
+ avatarHiRes = MediaResourceManager.get().requestMediaResourceSync(
+ hiResDesc.buildSyncMediaRequest(context));
+ }
+ }
+
+ // We have to make copies of the bitmaps to hand to the NotificationManager
+ // because the bitmap in the ImageResource is managed and will automatically
+ // get released.
+ Bitmap avatarBitmap = Bitmap.createBitmap(avatarImage.getBitmap());
+ Bitmap avatarHiResBitmap = (avatarHiRes != null) ?
+ Bitmap.createBitmap(avatarHiRes.getBitmap()) : null;
+ sendNotification(state, avatarBitmap, avatarHiResBitmap);
+ return;
+ } finally {
+ avatarImage.release();
+ if (avatarHiRes != null) {
+ avatarHiRes.release();
+ }
+ }
+ }
+ }
+ // We have no avatar. Post the notification anyway.
+ sendNotification(state, null, null);
+ }
+
+ /**
+ * Returns the thumbnailUri from the avatar URI, or null if avatar URI does not have thumbnail.
+ */
+ private static Uri getThumbnailUri(final Uri avatarUri) {
+ Uri localUri = null;
+ final String avatarType = AvatarUriUtil.getAvatarType(avatarUri);
+ if (TextUtils.equals(avatarType, AvatarUriUtil.TYPE_LOCAL_RESOURCE_URI)) {
+ localUri = AvatarUriUtil.getPrimaryUri(avatarUri);
+ } else if (UriUtil.isLocalResourceUri(avatarUri)) {
+ localUri = avatarUri;
+ }
+ if (localUri != null && localUri.getAuthority().equals(ContactsContract.AUTHORITY)) {
+ // Contact photos are of the form: content://com.android.contacts/contacts/123/photo
+ final List<String> pathParts = localUri.getPathSegments();
+ if (pathParts.size() == 3 &&
+ pathParts.get(2).equals(Contacts.Photo.CONTENT_DIRECTORY)) {
+ return localUri;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Returns the displayPhotoUri from the avatar URI, or null if avatar URI
+ * does not have a displayPhotoUri.
+ */
+ private static Uri getDisplayPhotoUri(final Uri avatarUri) {
+ final Uri thumbnailUri = getThumbnailUri(avatarUri);
+ if (thumbnailUri == null) {
+ return null;
+ }
+ final List<String> originalPaths = thumbnailUri.getPathSegments();
+ final int originalPathsSize = originalPaths.size();
+ final StringBuilder newPathBuilder = new StringBuilder();
+ // Change content://com.android.contacts/contacts("_corp")/123/photo to
+ // content://com.android.contacts/contacts("_corp")/123/display_photo
+ for (int i = 0; i < originalPathsSize; i++) {
+ newPathBuilder.append('/');
+ if (i == 2) {
+ newPathBuilder.append(ContactsContract.Contacts.Photo.DISPLAY_PHOTO);
+ } else {
+ newPathBuilder.append(originalPaths.get(i));
+ }
+ }
+ return thumbnailUri.buildUpon().path(newPathBuilder.toString()).build();
+ }
+
+ private static ImageResource requestContactDisplayPhoto(final Context context,
+ final Uri displayPhotoUri) {
+ final UriImageRequestDescriptor bgDescriptor =
+ new UriImageRequestDescriptor(displayPhotoUri,
+ sWearableImageWidth,
+ sWearableImageHeight,
+ false, /* allowCompression */
+ true, /* isStatic */
+ false /* cropToCircle */,
+ ImageUtils.DEFAULT_CIRCLE_BACKGROUND_COLOR /* circleBackgroundColor */,
+ ImageUtils.DEFAULT_CIRCLE_STROKE_COLOR /* circleStrokeColor */);
+ return MediaResourceManager.get().requestMediaResourceSync(
+ bgDescriptor.buildSyncMediaRequest(context));
+ }
+
+ private static void createMessageNotification(final boolean silent,
+ final String conversationId) {
+ final NotificationState state = MessageNotificationState.getNotificationState();
+ final boolean softSound = DataModel.get().isNewMessageObservable(conversationId);
+ if (state == null) {
+ cancel(PendingIntentConstants.SMS_NOTIFICATION_ID);
+ if (softSound && !TextUtils.isEmpty(conversationId)) {
+ final Uri ringtoneUri = getNotificationRingtoneUriForConversationId(conversationId);
+ playObservableConversationNotificationSound(ringtoneUri);
+ }
+ return;
+ }
+ processAndSend(state, silent, softSound);
+
+ // The rest of the logic here is for supporting Android Wear devices, specifically for when
+ // we are notifying about multiple conversations. In that case, the Inbox-style summary
+ // notification (which we already processed above) appears on the phone (as it always has),
+ // but wearables show per-conversation notifications, bundled together in a group.
+
+ // It is valid to replace a notification group with another group with fewer conversations,
+ // or even with one notification for a single conversation. In either case, we need to
+ // explicitly cancel any children from the old group which are not being notified about now.
+ final Context context = Factory.get().getApplicationContext();
+ final ConversationIdSet oldGroupChildIds = getGroupChildIds(context);
+ if (oldGroupChildIds != null && oldGroupChildIds.size() > 0) {
+ cancelStaleGroupChildren(oldGroupChildIds, state);
+ }
+
+ // Send per-conversation notifications (if there are multiple conversations).
+ final ConversationIdSet groupChildIds = new ConversationIdSet();
+ if (state instanceof MultiConversationNotificationState) {
+ for (final NotificationState child :
+ ((MultiConversationNotificationState) state).mChildren) {
+ processAndSend(child, true /* silent */, softSound);
+ if (child.mConversationIds != null) {
+ groupChildIds.add(child.mConversationIds.first());
+ }
+ }
+ }
+
+ // Record the new set of group children.
+ writeGroupChildIds(context, groupChildIds);
+ }
+
+ private static void updateBuilderAudioVibrate(final NotificationState state,
+ final NotificationCompat.Builder notifBuilder, final boolean silent,
+ final Uri ringtoneUri, final String conversationId) {
+ int defaults = Notification.DEFAULT_LIGHTS;
+ if (!silent) {
+ final BuglePrefs prefs = Factory.get().getApplicationPrefs();
+ final long latestNotificationTimestamp = prefs.getLong(
+ BuglePrefsKeys.LATEST_NOTIFICATION_MESSAGE_TIMESTAMP, Long.MIN_VALUE);
+ final long latestReceivedTimestamp = state.getLatestReceivedTimestamp();
+ prefs.putLong(
+ BuglePrefsKeys.LATEST_NOTIFICATION_MESSAGE_TIMESTAMP,
+ Math.max(latestNotificationTimestamp, latestReceivedTimestamp));
+ if (latestReceivedTimestamp > latestNotificationTimestamp) {
+ synchronized (mLock) {
+ // Find out the last time we dinged for this conversation
+ Long lastTime = sLastMessageDingTime.get(conversationId);
+ if (sTimeBetweenDingsMs == 0) {
+ sTimeBetweenDingsMs = BugleGservices.get().getInt(
+ BugleGservicesKeys.NOTIFICATION_TIME_BETWEEN_RINGS_SECONDS,
+ BugleGservicesKeys.NOTIFICATION_TIME_BETWEEN_RINGS_SECONDS_DEFAULT) *
+ 1000;
+ }
+ if (lastTime == null
+ || SystemClock.elapsedRealtime() - lastTime > sTimeBetweenDingsMs) {
+ sLastMessageDingTime.put(conversationId, SystemClock.elapsedRealtime());
+ notifBuilder.setSound(ringtoneUri);
+ if (shouldVibrate(state)) {
+ defaults |= Notification.DEFAULT_VIBRATE;
+ }
+ }
+ }
+ }
+ }
+ notifBuilder.setDefaults(defaults);
+ }
+
+ // TODO: this doesn't seem to be defined in NotificationCompat yet. Temporarily
+ // define it here until it makes its way from Notification -> NotificationCompat.
+ /**
+ * Notification category: incoming direct message (SMS, instant message, etc.).
+ */
+ private static final String CATEGORY_MESSAGE = "msg";
+
+ private static void sendNotification(final NotificationState notificationState,
+ final Bitmap avatarIcon, final Bitmap avatarHiRes) {
+ final Context context = Factory.get().getApplicationContext();
+ if (notificationState.mCanceled) {
+ if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
+ LogUtil.d(TAG, "sendNotification: Notification already cancelled; dropping it");
+ }
+ return;
+ }
+
+ synchronized (sPendingNotifications) {
+ if (sPendingNotifications.contains(notificationState)) {
+ sPendingNotifications.remove(notificationState);
+ }
+ }
+
+ notificationState.mNotificationBuilder
+ .setSmallIcon(notificationState.getIcon())
+ .setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
+ .setColor(context.getResources().getColor(R.color.notification_accent_color))
+// .setPublicVersion(null) // TODO: when/if we ever support different
+ // text on the lockscreen, instead of "contents hidden"
+ .setCategory(CATEGORY_MESSAGE);
+
+ if (avatarIcon != null) {
+ notificationState.mNotificationBuilder.setLargeIcon(avatarIcon);
+ }
+
+ if (notificationState.mParticipantContactUris != null &&
+ notificationState.mParticipantContactUris.size() > 0) {
+ for (final Uri contactUri : notificationState.mParticipantContactUris) {
+ notificationState.mNotificationBuilder.addPerson(contactUri.toString());
+ }
+ }
+
+ final Uri attachmentUri = notificationState.getAttachmentUri();
+ final String attachmentType = notificationState.getAttachmentType();
+ Bitmap attachmentBitmap = null;
+
+ // For messages with photo/video attachment, request an image to show in the notification.
+ if (attachmentUri != null && notificationState.mNotificationStyle != null &&
+ (notificationState.mNotificationStyle instanceof
+ NotificationCompat.BigPictureStyle) &&
+ (ContentType.isImageType(attachmentType) ||
+ ContentType.isVideoType(attachmentType))) {
+ final boolean isVideo = ContentType.isVideoType(attachmentType);
+
+ MediaRequest<ImageResource> imageRequest;
+ if (isVideo) {
+ Assert.isTrue(VideoThumbnailRequest.shouldShowIncomingVideoThumbnails());
+ final MessagePartVideoThumbnailRequestDescriptor videoDescriptor =
+ new MessagePartVideoThumbnailRequestDescriptor(attachmentUri);
+ imageRequest = videoDescriptor.buildSyncMediaRequest(context);
+ } else {
+ final UriImageRequestDescriptor imageDescriptor =
+ new UriImageRequestDescriptor(attachmentUri,
+ sWearableImageWidth,
+ sWearableImageHeight,
+ false /* allowCompression */,
+ true /* isStatic */,
+ false /* cropToCircle */,
+ ImageUtils.DEFAULT_CIRCLE_BACKGROUND_COLOR /* circleBackgroundColor */,
+ ImageUtils.DEFAULT_CIRCLE_STROKE_COLOR /* circleStrokeColor */);
+ imageRequest = imageDescriptor.buildSyncMediaRequest(context);
+ }
+ final ImageResource imageResource =
+ MediaResourceManager.get().requestMediaResourceSync(imageRequest);
+ if (imageResource != null) {
+ try {
+ // Copy the bitmap, because the one in the ImageResource is managed by
+ // MediaResourceManager.
+ Bitmap imageResourceBitmap = imageResource.getBitmap();
+ Config config = imageResourceBitmap.getConfig();
+
+ // Make sure our bitmap has a valid format.
+ if (config == null) {
+ config = Bitmap.Config.ARGB_8888;
+ }
+ attachmentBitmap = imageResourceBitmap.copy(config, true);
+ } finally {
+ imageResource.release();
+ }
+ }
+ }
+
+ fireOffNotification(notificationState, attachmentBitmap, avatarIcon, avatarHiRes);
+ }
+
+ private static void fireOffNotification(final NotificationState notificationState,
+ final Bitmap attachmentBitmap, final Bitmap avatarBitmap, Bitmap avatarHiResBitmap) {
+ if (notificationState.mCanceled) {
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "Firing off notification, but notification already canceled");
+ }
+ return;
+ }
+
+ final Context context = Factory.get().getApplicationContext();
+
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "MMS picture loaded, bitmap: " + attachmentBitmap);
+ }
+
+ final NotificationCompat.Builder notifBuilder = notificationState.mNotificationBuilder;
+ notifBuilder.setStyle(notificationState.mNotificationStyle);
+ notifBuilder.setColor(context.getResources().getColor(R.color.notification_accent_color));
+
+ final WearableExtender wearableExtender = new WearableExtender();
+ setWearableGroupOptions(notifBuilder, notificationState);
+
+ if (avatarHiResBitmap != null) {
+ wearableExtender.setBackground(avatarHiResBitmap);
+ } else if (avatarBitmap != null) {
+ // Nothing to do here; we already set avatarBitmap as the notification icon
+ } else {
+ final Bitmap defaultBackground = BitmapFactory.decodeResource(
+ context.getResources(), R.drawable.bg_sms);
+ wearableExtender.setBackground(defaultBackground);
+ }
+
+ if (notificationState instanceof MultiMessageNotificationState) {
+ if (attachmentBitmap != null) {
+ // When we've got a picture attachment, we do some switcheroo trickery. When
+ // the notification is expanded, we show the picture as a bigPicture. The small
+ // icon shows the sender's avatar. When that same notification is collapsed, the
+ // picture is shown in the location where the avatar is normally shown. The lines
+ // below make all that happen.
+
+ // Here we're taking the picture attachment and making a small, scaled, center
+ // cropped version of the picture we can stuff into the place where the avatar
+ // goes when the notification is collapsed.
+ final Bitmap smallBitmap = ImageUtils.scaleCenterCrop(attachmentBitmap, sIconWidth,
+ sIconHeight);
+ ((NotificationCompat.BigPictureStyle) notificationState.mNotificationStyle)
+ .bigPicture(attachmentBitmap)
+ .bigLargeIcon(avatarBitmap);
+ notificationState.mNotificationBuilder.setLargeIcon(smallBitmap);
+
+ // Add a wearable page with no visible card so you can more easily see the photo.
+ final NotificationCompat.Builder photoPageNotifBuilder =
+ new NotificationCompat.Builder(Factory.get().getApplicationContext());
+ final WearableExtender photoPageWearableExtender = new WearableExtender();
+ photoPageWearableExtender.setHintShowBackgroundOnly(true);
+ if (attachmentBitmap != null) {
+ final Bitmap wearBitmap = ImageUtils.scaleCenterCrop(attachmentBitmap,
+ sWearableImageWidth, sWearableImageHeight);
+ photoPageWearableExtender.setBackground(wearBitmap);
+ }
+ photoPageNotifBuilder.extend(photoPageWearableExtender);
+ wearableExtender.addPage(photoPageNotifBuilder.build());
+ }
+
+ maybeAddWearableConversationLog(wearableExtender,
+ (MultiMessageNotificationState) notificationState);
+ addDownloadMmsAction(notifBuilder, wearableExtender, notificationState);
+ addWearableVoiceReplyAction(wearableExtender, notificationState);
+ }
+
+ // Apply the wearable options and build & post the notification
+ notifBuilder.extend(wearableExtender);
+ doNotify(notifBuilder.build(), notificationState);
+ }
+
+ private static void setWearableGroupOptions(final NotificationCompat.Builder notifBuilder,
+ final NotificationState notificationState) {
+ final String groupKey = "groupkey";
+ if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(TAG, "Group key (for wearables)=" + groupKey);
+ }
+ if (notificationState instanceof MultiConversationNotificationState) {
+ notifBuilder.setGroup(groupKey).setGroupSummary(true);
+ } else if (notificationState instanceof BundledMessageNotificationState) {
+ final int order = ((BundledMessageNotificationState) notificationState).mGroupOrder;
+ // Convert the order to a zero-padded string ("00", "01", "02", etc).
+ // The Wear library orders notifications within a bundle lexicographically
+ // by the sort key, hence the need for zeroes to preserve the ordering.
+ final String sortKey = String.format(Locale.US, "%02d", order);
+ notifBuilder.setGroup(groupKey).setSortKey(sortKey);
+ }
+ }
+
+ private static void maybeAddWearableConversationLog(
+ final WearableExtender wearableExtender,
+ final MultiMessageNotificationState notificationState) {
+ if (!isWearCompanionAppInstalled()) {
+ return;
+ }
+ final String convId = notificationState.mConversationIds.first();
+ ConversationLineInfo convInfo = notificationState.mConvList.mConvInfos.get(0);
+ final Notification page = MessageNotificationState.buildConversationPageForWearable(
+ convId,
+ convInfo.mParticipantCount);
+ if (page != null) {
+ wearableExtender.addPage(page);
+ }
+ }
+
+ private static void addWearableVoiceReplyAction(
+ final WearableExtender wearableExtender, final NotificationState notificationState) {
+ if (!(notificationState instanceof MultiMessageNotificationState)) {
+ return;
+ }
+ final MultiMessageNotificationState multiMessageNotificationState =
+ (MultiMessageNotificationState) notificationState;
+ final Context context = Factory.get().getApplicationContext();
+
+ final String conversationId = notificationState.mConversationIds.first();
+ final ConversationLineInfo convInfo =
+ multiMessageNotificationState.mConvList.mConvInfos.get(0);
+ final String selfId = convInfo.mSelfParticipantId;
+
+ final boolean requiresMms =
+ MmsSmsUtils.getRequireMmsForEmailAddress(
+ convInfo.mIncludeEmailAddress, convInfo.mSubId) ||
+ (convInfo.mIsGroup && MmsUtils.groupMmsEnabled(convInfo.mSubId));
+
+ final int requestCode = multiMessageNotificationState.getReplyIntentRequestCode();
+ final PendingIntent replyPendingIntent = UIIntents.get()
+ .getPendingIntentForSendingMessageToConversation(context,
+ conversationId, selfId, requiresMms, requestCode);
+
+ final int replyLabelRes = requiresMms ? R.string.notification_reply_via_mms :
+ R.string.notification_reply_via_sms;
+
+ final NotificationCompat.Action.Builder actionBuilder =
+ new NotificationCompat.Action.Builder(R.drawable.ic_wear_reply,
+ context.getString(replyLabelRes), replyPendingIntent);
+ final String[] choices = context.getResources().getStringArray(
+ R.array.notification_reply_choices);
+ final RemoteInput remoteInput = new RemoteInput.Builder(Intent.EXTRA_TEXT).setLabel(
+ context.getString(R.string.notification_reply_prompt)).
+ setChoices(choices)
+ .build();
+ actionBuilder.addRemoteInput(remoteInput);
+ wearableExtender.addAction(actionBuilder.build());
+ }
+
+ private static void addDownloadMmsAction(final NotificationCompat.Builder notifBuilder,
+ final WearableExtender wearableExtender, final NotificationState notificationState) {
+ if (!(notificationState instanceof MultiMessageNotificationState)) {
+ return;
+ }
+ final MultiMessageNotificationState multiMessageNotificationState =
+ (MultiMessageNotificationState) notificationState;
+ final ConversationLineInfo convInfo =
+ multiMessageNotificationState.mConvList.mConvInfos.get(0);
+ if (!convInfo.getDoesLatestMessageNeedDownload()) {
+ return;
+ }
+ final String messageId = convInfo.getLatestMessageId();
+ if (messageId == null) {
+ // No message Id, no download for you
+ return;
+ }
+ final Context context = Factory.get().getApplicationContext();
+ final PendingIntent downloadPendingIntent =
+ RedownloadMmsAction.getPendingIntentForRedownloadMms(context, messageId);
+
+ final NotificationCompat.Action.Builder actionBuilder =
+ new NotificationCompat.Action.Builder(R.drawable.ic_file_download_light,
+ context.getString(R.string.notification_download_mms),
+ downloadPendingIntent);
+ final NotificationCompat.Action downloadAction = actionBuilder.build();
+ notifBuilder.addAction(downloadAction);
+
+ // Support the action on a wearable device as well
+ wearableExtender.addAction(downloadAction);
+ }
+
+ private static synchronized void doNotify(final Notification notification,
+ final NotificationState notificationState) {
+ if (notification == null) {
+ return;
+ }
+ final int type = notificationState.mType;
+ final ConversationIdSet conversationIds = notificationState.mConversationIds;
+ final boolean isBundledNotification =
+ (notificationState instanceof BundledMessageNotificationState);
+
+ // Mark the notification as finished
+ notificationState.mCanceled = true;
+
+ final NotificationManagerCompat notificationManager =
+ NotificationManagerCompat.from(Factory.get().getApplicationContext());
+ // Only need conversationId for tags with a single conversation.
+ String conversationId = null;
+ if (conversationIds != null && conversationIds.size() == 1) {
+ conversationId = conversationIds.first();
+ }
+ final String notificationTag = buildNotificationTag(type,
+ conversationId, isBundledNotification);
+
+ notification.flags |= Notification.FLAG_AUTO_CANCEL;
+ notification.defaults |= Notification.DEFAULT_LIGHTS;
+
+ notificationManager.notify(notificationTag, type, notification);
+
+ LogUtil.i(TAG, "Notifying for conversation " + conversationId + "; "
+ + "tag = " + notificationTag + ", type = " + type);
+ }
+
+ // This is the message string used in each line of an inboxStyle notification.
+ // TODO: add attachment type
+ static CharSequence formatInboxMessage(final String sender,
+ final CharSequence message, final Uri attachmentUri, final String attachmentType) {
+ final Context context = Factory.get().getApplicationContext();
+ final TextAppearanceSpan notificationSenderSpan = new TextAppearanceSpan(
+ context, R.style.NotificationSenderText);
+
+ final TextAppearanceSpan notificationTertiaryText = new TextAppearanceSpan(
+ context, R.style.NotificationTertiaryText);
+
+ final SpannableStringBuilder spannableStringBuilder = new SpannableStringBuilder();
+ if (!TextUtils.isEmpty(sender)) {
+ spannableStringBuilder.append(sender);
+ spannableStringBuilder.setSpan(notificationSenderSpan, 0, sender.length(), 0);
+ }
+ final String separator = context.getString(R.string.notification_separator);
+
+ if (!TextUtils.isEmpty(message)) {
+ if (spannableStringBuilder.length() > 0) {
+ spannableStringBuilder.append(separator);
+ }
+ final int start = spannableStringBuilder.length();
+ spannableStringBuilder.append(message);
+ spannableStringBuilder.setSpan(notificationTertiaryText, start,
+ start + message.length(), 0);
+ }
+ if (attachmentUri != null) {
+ if (spannableStringBuilder.length() > 0) {
+ spannableStringBuilder.append(separator);
+ }
+ spannableStringBuilder.append(formatAttachmentTag(null, attachmentType));
+ }
+ return spannableStringBuilder;
+ }
+
+ protected static CharSequence buildColonSeparatedMessage(
+ final String title, final CharSequence content, final Uri attachmentUri,
+ final String attachmentType) {
+ return buildBoldedMessage(title, content, attachmentUri, attachmentType,
+ R.string.notification_ticker_separator);
+ }
+
+ protected static CharSequence buildSpaceSeparatedMessage(
+ final String title, final CharSequence content, final Uri attachmentUri,
+ final String attachmentType) {
+ return buildBoldedMessage(title, content, attachmentUri, attachmentType,
+ R.string.notification_space_separator);
+ }
+
+ /**
+ * buildBoldedMessage - build a formatted message where the title is bold, there's a
+ * separator, then the message.
+ */
+ private static CharSequence buildBoldedMessage(
+ final String title, final CharSequence message, final Uri attachmentUri,
+ final String attachmentType,
+ final int separatorId) {
+ final Context context = Factory.get().getApplicationContext();
+ final SpannableStringBuilder spanBuilder = new SpannableStringBuilder();
+
+ // Boldify the title (which is the sender's name)
+ if (!TextUtils.isEmpty(title)) {
+ spanBuilder.append(title);
+ spanBuilder.setSpan(new StyleSpan(Typeface.BOLD), 0, title.length(),
+ Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+ if (!TextUtils.isEmpty(message)) {
+ if (spanBuilder.length() > 0) {
+ spanBuilder.append(context.getString(separatorId));
+ }
+ spanBuilder.append(message);
+ }
+ if (attachmentUri != null) {
+ if (spanBuilder.length() > 0) {
+ final String separator = context.getString(R.string.notification_separator);
+ spanBuilder.append(separator);
+ }
+ spanBuilder.append(formatAttachmentTag(null, attachmentType));
+ }
+ return spanBuilder;
+ }
+
+ static CharSequence formatAttachmentTag(final String author, final String attachmentType) {
+ final Context context = Factory.get().getApplicationContext();
+ final TextAppearanceSpan notificationSecondaryText = new TextAppearanceSpan(
+ context, R.style.NotificationSecondaryText);
+ final SpannableStringBuilder spannableStringBuilder = new SpannableStringBuilder();
+ if (!TextUtils.isEmpty(author)) {
+ final TextAppearanceSpan notificationSenderSpan = new TextAppearanceSpan(
+ context, R.style.NotificationSenderText);
+ spannableStringBuilder.append(author);
+ spannableStringBuilder.setSpan(notificationSenderSpan, 0, author.length(), 0);
+ final String separator = context.getString(R.string.notification_separator);
+ spannableStringBuilder.append(separator);
+ }
+ final int start = spannableStringBuilder.length();
+ // The default attachment type is an image, since that's what was originally
+ // supported. When there's no content type, assume it's an image.
+ int message = R.string.notification_picture;
+ if (ContentType.isAudioType(attachmentType)) {
+ message = R.string.notification_audio;
+ } else if (ContentType.isVideoType(attachmentType)) {
+ message = R.string.notification_video;
+ } else if (ContentType.isVCardType(attachmentType)) {
+ message = R.string.notification_vcard;
+ }
+ spannableStringBuilder.append(context.getText(message));
+ spannableStringBuilder.setSpan(notificationSecondaryText, start,
+ spannableStringBuilder.length(), 0);
+ return spannableStringBuilder;
+ }
+
+ /**
+ * Play the observable conversation notification sound (it's the regular notification sound, but
+ * played at half-volume)
+ */
+ private static void playObservableConversationNotificationSound(final Uri ringtoneUri) {
+ final Context context = Factory.get().getApplicationContext();
+ final AudioManager audioManager = (AudioManager) context
+ .getSystemService(Context.AUDIO_SERVICE);
+ final boolean silenced =
+ audioManager.getRingerMode() != AudioManager.RINGER_MODE_NORMAL;
+ if (silenced) {
+ return;
+ }
+
+ final NotificationPlayer player = new NotificationPlayer(LogUtil.BUGLE_TAG);
+ player.play(ringtoneUri, false,
+ AudioManager.STREAM_NOTIFICATION,
+ OBSERVABLE_CONVERSATION_NOTIFICATION_VOLUME);
+
+ // Stop the sound after five seconds to handle continuous ringtones
+ ThreadUtil.getMainThreadHandler().postDelayed(new Runnable() {
+ @Override
+ public void run() {
+ player.stop();
+ }
+ }, 5000);
+ }
+
+ public static boolean isWearCompanionAppInstalled() {
+ boolean found = false;
+ try {
+ Factory.get().getApplicationContext().getPackageManager()
+ .getPackageInfo(WEARABLE_COMPANION_APP_PACKAGE, 0);
+ found = true;
+ } catch (final NameNotFoundException e) {
+ // Ignore; found is already false
+ }
+ return found;
+ }
+
+ /**
+ * When we go to the conversation list, call this to mark all messages as seen. That means
+ * we won't show a notification again for the same message.
+ */
+ public static void markAllMessagesAsSeen() {
+ MarkAsSeenAction.markAllAsSeen();
+ resetLastMessageDing(null); // reset the ding timeout for all conversations
+ }
+
+ /**
+ * When we open a particular conversation, call this to mark all messages as read.
+ */
+ public static void markMessagesAsRead(final String conversationId) {
+ MarkAsReadAction.markAsRead(conversationId);
+ resetLastMessageDing(conversationId);
+ }
+
+ /**
+ * Returns the conversation ids of all active, grouped notifications, or
+ * {code null} if no notifications are currently active and grouped.
+ */
+ private static ConversationIdSet getGroupChildIds(final Context context) {
+ final String prefKey = context.getString(R.string.notifications_group_children_key);
+ final String groupChildIdsText = BuglePrefs.getApplicationPrefs().getString(prefKey, "");
+ if (!TextUtils.isEmpty(groupChildIdsText)) {
+ return ConversationIdSet.createSet(groupChildIdsText);
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Records the conversation ids of the currently active grouped notifications.
+ */
+ private static void writeGroupChildIds(final Context context,
+ final ConversationIdSet childIds) {
+ final ConversationIdSet oldChildIds = getGroupChildIds(context);
+ if (childIds.equals(oldChildIds)) {
+ return;
+ }
+ final String prefKey = context.getString(R.string.notifications_group_children_key);
+ BuglePrefs.getApplicationPrefs().putString(prefKey, childIds.getDelimitedString());
+ }
+
+ /**
+ * Reset the timer for a notification ding on a particular conversation or all conversations.
+ */
+ public static void resetLastMessageDing(final String conversationId) {
+ synchronized (mLock) {
+ if (TextUtils.isEmpty(conversationId)) {
+ // reset all conversation dings
+ sLastMessageDingTime.clear();
+ } else {
+ sLastMessageDingTime.remove(conversationId);
+ }
+ }
+ }
+
+ public static void notifyEmergencySmsFailed(final String emergencyNumber,
+ final String conversationId) {
+ final Context context = Factory.get().getApplicationContext();
+
+ final CharSequence line1 = MessageNotificationState.applyWarningTextColor(context,
+ context.getString(R.string.notification_emergency_send_failure_line1,
+ emergencyNumber));
+ final String line2 = context.getString(R.string.notification_emergency_send_failure_line2,
+ emergencyNumber);
+ final PendingIntent destinationIntent = UIIntents.get()
+ .getPendingIntentForConversationActivity(context, conversationId, null /* draft */);
+
+ final NotificationCompat.Builder builder = new NotificationCompat.Builder(context);
+ builder.setTicker(line1)
+ .setContentTitle(line1)
+ .setContentText(line2)
+ .setStyle(new NotificationCompat.BigTextStyle(builder).bigText(line2))
+ .setSmallIcon(R.drawable.ic_failed_light)
+ .setContentIntent(destinationIntent)
+ .setSound(UriUtil.getUriForResourceId(context, R.raw.message_failure));
+
+ final String tag = context.getPackageName() + ":emergency_sms_error";
+ NotificationManagerCompat.from(context).notify(
+ tag,
+ PendingIntentConstants.MSG_SEND_ERROR,
+ builder.build());
+ }
+}
+