diff options
author | Mike Dodd <mdodd@google.com> | 2015-08-11 11:16:59 -0700 |
---|---|---|
committer | Mike Dodd <mdodd@google.com> | 2015-08-12 08:58:28 -0700 |
commit | 461a34b466cb4b13dbbc2ec6330b31e217b2ac4e (patch) | |
tree | bc4b489af52d0e2521e21167d2ad76a47256f348 /src/com/android/messaging/datamodel/BugleNotifications.java | |
parent | 8b3e2b9c1b0a09423a7ba5d1091b9192106502f8 (diff) | |
download | android_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.java | 1221 |
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()); + } +} + |