blob: b796e736ca17cb89d6d207bcf3ffcc8c7a3685a2 [file] [log] [blame]
/*
* 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());
}
}