/**
* Copyright (C) 2011 Whisper Systems
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package org.thoughtcrime.securesms.notifications;
import android.app.AlarmManager;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
import android.media.AudioAttributes;
import android.media.AudioManager;
import android.media.Ringtone;
import android.media.RingtoneManager;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Build;
import android.service.notification.StatusBarNotification;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.v4.app.NotificationManagerCompat;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.TextUtils;
import android.text.style.StyleSpan;
import android.util.Log;
import org.thoughtcrime.redphone.ui.NotificationBarManager;
import org.thoughtcrime.redphone.util.Util;
import org.thoughtcrime.securesms.ConversationActivity;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.crypto.MasterSecret;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.MessagingDatabase.MarkedMessageInfo;
import org.thoughtcrime.securesms.database.MmsSmsDatabase;
import org.thoughtcrime.securesms.database.PushDatabase;
import org.thoughtcrime.securesms.database.SmsDatabase;
import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.mms.SlideDeck;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientFactory;
import org.thoughtcrime.securesms.recipients.Recipients;
import org.thoughtcrime.securesms.service.KeyCachingService;
import org.thoughtcrime.securesms.service.MessageRetrievalService;
import org.thoughtcrime.securesms.util.ServiceUtil;
import org.thoughtcrime.securesms.util.SpanUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.webrtc.CallNotificationBuilder;
import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope;
import java.util.HashSet;
import java.util.List;
import java.util.ListIterator;
import java.util.Set;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import me.leolin.shortcutbadger.ShortcutBadger;
/**
* Handles posting system notifications for new messages.
*
*
* @author Moxie Marlinspike
*/
public class MessageNotifier {
private static final String TAG = MessageNotifier.class.getSimpleName();
public static final String EXTRA_REMOTE_REPLY = "extra_remote_reply";
private static final int SUMMARY_NOTIFICATION_ID = 1338;
private static final String NOTIFICATION_GROUP = "messages";
private static final long MIN_AUDIBLE_PERIOD_MILLIS = TimeUnit.SECONDS.toMillis(2);
private static final long DESKTOP_ACTIVITY_PERIOD = TimeUnit.MINUTES.toMillis(1);
private volatile static long visibleThread = -1;
private volatile static long lastDesktopActivityTimestamp = -1;
private volatile static long lastAudibleNotification = -1;
private static final CancelableExecutor executor = new CancelableExecutor();
public static void setVisibleThread(long threadId) {
visibleThread = threadId;
}
public static void setLastDesktopActivityTimestamp(long timestamp) {
lastDesktopActivityTimestamp = timestamp;
}
public static void notifyMessageDeliveryFailed(Context context, Recipients recipients, long threadId) {
if (visibleThread == threadId) {
sendInThreadNotification(context, recipients);
} else {
Intent intent = new Intent(context, ConversationActivity.class);
intent.putExtra(ConversationActivity.RECIPIENTS_EXTRA, recipients.getIds());
intent.putExtra(ConversationActivity.THREAD_ID_EXTRA, threadId);
intent.setData((Uri.parse("custom://" + System.currentTimeMillis())));
FailedNotificationBuilder builder = new FailedNotificationBuilder(context, TextSecurePreferences.getNotificationPrivacy(context), intent);
((NotificationManager)context.getSystemService(Context.NOTIFICATION_SERVICE))
.notify((int)threadId, builder.build());
}
}
public static void cancelDelayedNotifications() {
executor.cancel();
}
private static void cancelActiveNotifications(@NonNull Context context) {
NotificationManager notifications = ServiceUtil.getNotificationManager(context);
notifications.cancel(SUMMARY_NOTIFICATION_ID);
if (Build.VERSION.SDK_INT >= 23) {
try {
StatusBarNotification[] activeNotifications = notifications.getActiveNotifications();
for (StatusBarNotification activeNotification : activeNotifications) {
if (activeNotification.getId() != NotificationBarManager.RED_PHONE_NOTIFICATION) {
notifications.cancel(activeNotification.getId());
}
}
} catch (Throwable e) {
// XXX Appears to be a ROM bug, see #6043
Log.w(TAG, e);
notifications.cancelAll();
}
}
}
private static void cancelOrphanedNotifications(@NonNull Context context, NotificationState notificationState) {
if (Build.VERSION.SDK_INT >= 23) {
try {
NotificationManager notifications = ServiceUtil.getNotificationManager(context);
StatusBarNotification[] activeNotifications = notifications.getActiveNotifications();
for (StatusBarNotification notification : activeNotifications) {
boolean validNotification = false;
if (notification.getId() != SUMMARY_NOTIFICATION_ID &&
notification.getId() != NotificationBarManager.RED_PHONE_NOTIFICATION &&
notification.getId() != CallNotificationBuilder.WEBRTC_NOTIFICATION &&
notification.getId() != KeyCachingService.SERVICE_RUNNING_ID &&
notification.getId() != MessageRetrievalService.FOREGROUND_ID)
{
for (NotificationItem item : notificationState.getNotifications()) {
if (notification.getId() == (SUMMARY_NOTIFICATION_ID + item.getThreadId())) {
validNotification = true;
break;
}
}
if (!validNotification) {
notifications.cancel(notification.getId());
}
}
}
} catch (Throwable e) {
// XXX Android ROM Bug, see #6043
Log.w(TAG, e);
}
}
}
public static void updateNotification(@NonNull Context context, @Nullable MasterSecret masterSecret) {
if (!TextSecurePreferences.isNotificationsEnabled(context)) {
return;
}
updateNotification(context, masterSecret, false, false, 0);
}
public static void updateNotification(@NonNull Context context,
@Nullable MasterSecret masterSecret,
long threadId)
{
if (System.currentTimeMillis() - lastDesktopActivityTimestamp < DESKTOP_ACTIVITY_PERIOD) {
Log.w(TAG, "Scheduling delayed notification...");
executor.execute(new DelayedNotification(context, masterSecret, threadId));
} else {
updateNotification(context, masterSecret, false, threadId, true);
}
}
public static void updateNotification(@NonNull Context context,
@Nullable MasterSecret masterSecret,
boolean includePushDatabase,
long threadId)
{
updateNotification(context, masterSecret, includePushDatabase, threadId, true);
}
public static void updateNotification(@NonNull Context context,
@Nullable MasterSecret masterSecret,
boolean includePushDatabase,
long threadId,
boolean signal)
{
boolean isVisible = visibleThread == threadId;
ThreadDatabase threads = DatabaseFactory.getThreadDatabase(context);
Recipients recipients = DatabaseFactory.getThreadDatabase(context)
.getRecipientsForThreadId(threadId);
if (isVisible) {
List messageIds = threads.setRead(threadId, false);
MarkReadReceiver.process(context, messageIds);
}
if (!TextSecurePreferences.isNotificationsEnabled(context) ||
(recipients != null && recipients.isMuted()))
{
return;
}
if (isVisible) {
sendInThreadNotification(context, threads.getRecipientsForThreadId(threadId));
} else {
updateNotification(context, masterSecret, signal, includePushDatabase, 0);
}
}
private static void updateNotification(@NonNull Context context,
@Nullable MasterSecret masterSecret,
boolean signal,
boolean includePushDatabase,
int reminderCount)
{
Cursor telcoCursor = null;
Cursor pushCursor = null;
try {
telcoCursor = DatabaseFactory.getMmsSmsDatabase(context).getUnread();
pushCursor = DatabaseFactory.getPushDatabase(context).getPending();
if ((telcoCursor == null || telcoCursor.isAfterLast()) &&
(pushCursor == null || pushCursor.isAfterLast()))
{
cancelActiveNotifications(context);
updateBadge(context, 0);
clearReminder(context);
return;
}
NotificationState notificationState = constructNotificationState(context, masterSecret, telcoCursor);
if (includePushDatabase) {
appendPushNotificationState(context, notificationState, pushCursor);
}
if (signal && (System.currentTimeMillis() - lastAudibleNotification) < MIN_AUDIBLE_PERIOD_MILLIS) {
signal = false;
} else if (signal) {
lastAudibleNotification = System.currentTimeMillis();
}
if (notificationState.hasMultipleThreads()) {
if (Build.VERSION.SDK_INT >= 23) {
for (long threadId : notificationState.getThreads()) {
sendSingleThreadNotification(context, masterSecret, new NotificationState(notificationState.getNotificationsForThread(threadId)), false, true);
}
}
sendMultipleThreadNotification(context, notificationState, signal);
} else {
sendSingleThreadNotification(context, masterSecret, notificationState, signal, false);
}
cancelOrphanedNotifications(context, notificationState);
updateBadge(context, notificationState.getMessageCount());
if (signal) {
scheduleReminder(context, reminderCount);
}
} finally {
if (telcoCursor != null) telcoCursor.close();
if (pushCursor != null) pushCursor.close();
}
}
private static void sendSingleThreadNotification(@NonNull Context context,
@Nullable MasterSecret masterSecret,
@NonNull NotificationState notificationState,
boolean signal, boolean bundled)
{
if (notificationState.getNotifications().isEmpty()) {
if (!bundled) cancelActiveNotifications(context);
return;
}
SingleRecipientNotificationBuilder builder = new SingleRecipientNotificationBuilder(context, masterSecret, TextSecurePreferences.getNotificationPrivacy(context));
List notifications = notificationState.getNotifications();
Recipients recipients = notifications.get(0).getRecipients();
int notificationId = (int) (SUMMARY_NOTIFICATION_ID + (bundled ? notifications.get(0).getThreadId() : 0));
builder.setThread(notifications.get(0).getRecipients());
builder.setMessageCount(notificationState.getMessageCount());
builder.setPrimaryMessageBody(recipients, notifications.get(0).getIndividualRecipient(),
notifications.get(0).getText(), notifications.get(0).getSlideDeck());
builder.setContentIntent(notifications.get(0).getPendingIntent(context));
builder.setGroup(NOTIFICATION_GROUP);
long timestamp = notifications.get(0).getTimestamp();
if (timestamp != 0) builder.setWhen(timestamp);
builder.addActions(masterSecret,
notificationState.getMarkAsReadIntent(context, notificationId),
notificationState.getQuickReplyIntent(context, notifications.get(0).getRecipients()),
notificationState.getRemoteReplyIntent(context, notifications.get(0).getRecipients()));
builder.addAndroidAutoAction(notificationState.getAndroidAutoReplyIntent(context, notifications.get(0).getRecipients()),
notificationState.getAndroidAutoHeardIntent(context, notificationId), notifications.get(0).getTimestamp());
ListIterator iterator = notifications.listIterator(notifications.size());
while(iterator.hasPrevious()) {
NotificationItem item = iterator.previous();
builder.addMessageBody(item.getRecipients(), item.getIndividualRecipient(), item.getText());
}
if (signal) {
builder.setAlarms(notificationState.getRingtone(), notificationState.getVibrate());
builder.setTicker(notifications.get(0).getIndividualRecipient(),
notifications.get(0).getText());
}
if (!bundled) {
builder.setGroupSummary(true);
}
NotificationManagerCompat.from(context).notify(notificationId, builder.build());
}
private static void sendMultipleThreadNotification(@NonNull Context context,
@NonNull NotificationState notificationState,
boolean signal)
{
MultipleRecipientNotificationBuilder builder = new MultipleRecipientNotificationBuilder(context, TextSecurePreferences.getNotificationPrivacy(context));
List notifications = notificationState.getNotifications();
builder.setMessageCount(notificationState.getMessageCount(), notificationState.getThreadCount());
builder.setMostRecentSender(notifications.get(0).getIndividualRecipient());
builder.setGroup(NOTIFICATION_GROUP);
long timestamp = notifications.get(0).getTimestamp();
if (timestamp != 0) builder.setWhen(timestamp);
builder.addActions(notificationState.getMarkAsReadIntent(context, SUMMARY_NOTIFICATION_ID));
ListIterator iterator = notifications.listIterator(notifications.size());
while(iterator.hasPrevious()) {
NotificationItem item = iterator.previous();
builder.addMessageBody(item.getIndividualRecipient(), item.getText());
}
if (signal) {
builder.setAlarms(notificationState.getRingtone(), notificationState.getVibrate());
builder.setTicker(notifications.get(0).getIndividualRecipient(),
notifications.get(0).getText());
}
NotificationManagerCompat.from(context).notify(SUMMARY_NOTIFICATION_ID, builder.build());
}
private static void sendInThreadNotification(Context context, Recipients recipients) {
if (!TextSecurePreferences.isInThreadNotifications(context) ||
ServiceUtil.getAudioManager(context).getRingerMode() != AudioManager.RINGER_MODE_NORMAL)
{
return;
}
Uri uri = recipients != null ? recipients.getRingtone() : null;
if (uri == null) {
String ringtone = TextSecurePreferences.getNotificationRingtone(context);
if (ringtone == null) {
Log.w(TAG, "ringtone preference was null.");
return;
}
uri = Uri.parse(ringtone);
if (uri == null) {
Log.w(TAG, "couldn't parse ringtone uri " + ringtone);
return;
}
}
if (uri.toString().isEmpty()) {
Log.d(TAG, "ringtone uri is empty");
return;
}
Ringtone ringtone = RingtoneManager.getRingtone(context, uri);
if (ringtone == null) {
Log.w(TAG, "ringtone is null");
return;
}
if (Build.VERSION.SDK_INT >= 21) {
ringtone.setAudioAttributes(new AudioAttributes.Builder().setContentType(AudioAttributes.CONTENT_TYPE_UNKNOWN)
.setUsage(AudioAttributes.USAGE_NOTIFICATION_COMMUNICATION_INSTANT)
.build());
} else {
ringtone.setStreamType(AudioManager.STREAM_NOTIFICATION);
}
ringtone.play();
}
private static void appendPushNotificationState(@NonNull Context context,
@NonNull NotificationState notificationState,
@NonNull Cursor cursor)
{
PushDatabase.Reader reader = null;
SignalServiceEnvelope envelope;
try {
reader = DatabaseFactory.getPushDatabase(context).readerFor(cursor);
while ((envelope = reader.getNext()) != null) {
Recipients recipients = RecipientFactory.getRecipientsFromString(context, envelope.getSource(), false);
Recipient recipient = recipients.getPrimaryRecipient();
long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(recipients);
SpannableString body = new SpannableString(context.getString(R.string.MessageNotifier_locked_message));
body.setSpan(new StyleSpan(android.graphics.Typeface.ITALIC), 0, body.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
if (!recipients.isMuted()) {
notificationState.addNotification(new NotificationItem(recipient, recipients, null, threadId, body, 0, null));
}
}
} finally {
if (reader != null)
reader.close();
}
}
private static NotificationState constructNotificationState(@NonNull Context context,
@Nullable MasterSecret masterSecret,
@NonNull Cursor cursor)
{
NotificationState notificationState = new NotificationState();
MessageRecord record;
MmsSmsDatabase.Reader reader;
if (masterSecret == null) reader = DatabaseFactory.getMmsSmsDatabase(context).readerFor(cursor);
else reader = DatabaseFactory.getMmsSmsDatabase(context).readerFor(cursor, masterSecret);
while ((record = reader.getNext()) != null) {
Recipient recipient = record.getIndividualRecipient();
Recipients recipients = record.getRecipients();
long threadId = record.getThreadId();
CharSequence body = record.getDisplayBody();
Recipients threadRecipients = null;
SlideDeck slideDeck = null;
long timestamp = record.getTimestamp();
if (threadId != -1) {
threadRecipients = DatabaseFactory.getThreadDatabase(context).getRecipientsForThreadId(threadId);
}
if (SmsDatabase.Types.isDecryptInProgressType(record.getType()) || !record.getBody().isPlaintext()) {
body = SpanUtil.italic(context.getString(R.string.MessageNotifier_locked_message));
} else if (record.isMms() && TextUtils.isEmpty(body)) {
body = SpanUtil.italic(context.getString(R.string.MessageNotifier_media_message));
slideDeck = ((MediaMmsMessageRecord)record).getSlideDeck();
} else if (record.isMms() && !record.isMmsNotification()) {
String message = context.getString(R.string.MessageNotifier_media_message_with_text, body);
int italicLength = message.length() - body.length();
body = SpanUtil.italic(message, italicLength);
slideDeck = ((MediaMmsMessageRecord)record).getSlideDeck();
}
if (threadRecipients == null || !threadRecipients.isMuted()) {
notificationState.addNotification(new NotificationItem(recipient, recipients, threadRecipients, threadId, body, timestamp, slideDeck));
}
}
reader.close();
return notificationState;
}
private static void updateBadge(Context context, int count) {
try {
if (count == 0) ShortcutBadger.removeCount(context);
else ShortcutBadger.applyCount(context, count);
} catch (Throwable t) {
// NOTE :: I don't totally trust this thing, so I'm catching
// everything.
Log.w("MessageNotifier", t);
}
}
private static void scheduleReminder(Context context, int count) {
if (count >= TextSecurePreferences.getRepeatAlertsCount(context)) {
return;
}
AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
Intent alarmIntent = new Intent(ReminderReceiver.REMINDER_ACTION);
alarmIntent.putExtra("reminder_count", count);
PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, alarmIntent, PendingIntent.FLAG_CANCEL_CURRENT);
long timeout = TimeUnit.MINUTES.toMillis(2);
alarmManager.set(AlarmManager.RTC_WAKEUP, System.currentTimeMillis() + timeout, pendingIntent);
}
private static void clearReminder(Context context) {
Intent alarmIntent = new Intent(ReminderReceiver.REMINDER_ACTION);
PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, alarmIntent, PendingIntent.FLAG_CANCEL_CURRENT);
AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
alarmManager.cancel(pendingIntent);
}
public static class ReminderReceiver extends BroadcastReceiver {
public static final String REMINDER_ACTION = "org.thoughtcrime.securesms.MessageNotifier.REMINDER_ACTION";
@Override
public void onReceive(final Context context, final Intent intent) {
new AsyncTask() {
@Override
protected Void doInBackground(Void... params) {
MasterSecret masterSecret = KeyCachingService.getMasterSecret(context);
int reminderCount = intent.getIntExtra("reminder_count", 0);
MessageNotifier.updateNotification(context, masterSecret, true, false, reminderCount + 1);
return null;
}
}.execute();
}
}
public static class DeleteReceiver extends BroadcastReceiver {
public static final String DELETE_REMINDER_ACTION = "org.thoughtcrime.securesms.MessageNotifier.DELETE_REMINDER_ACTION";
@Override
public void onReceive(Context context, Intent intent) {
clearReminder(context);
}
}
private static class DelayedNotification implements Runnable {
private static final long DELAY = TimeUnit.SECONDS.toMillis(5);
private final AtomicBoolean canceled = new AtomicBoolean(false);
private final Context context;
private final MasterSecret masterSecret;
private final long threadId;
private final long delayUntil;
private DelayedNotification(Context context, MasterSecret masterSecret, long threadId) {
this.context = context;
this.masterSecret = masterSecret;
this.threadId = threadId;
this.delayUntil = System.currentTimeMillis() + DELAY;
}
@Override
public void run() {
MessageNotifier.updateNotification(context, masterSecret);
long delayMillis = delayUntil - System.currentTimeMillis();
Log.w(TAG, "Waiting to notify: " + delayMillis);
if (delayMillis > 0) {
Util.sleep(delayMillis);
}
if (!canceled.get()) {
Log.w(TAG, "Not canceled, notifying...");
MessageNotifier.updateNotification(context, masterSecret, false, threadId, true);
MessageNotifier.cancelDelayedNotifications();
} else {
Log.w(TAG, "Canceled, not notifying...");
}
}
public void cancel() {
canceled.set(true);
}
}
private static class CancelableExecutor {
private final Executor executor = Executors.newSingleThreadExecutor();
private final Set tasks = new HashSet<>();
public void execute(final DelayedNotification runnable) {
synchronized (tasks) {
tasks.add(runnable);
}
Runnable wrapper = new Runnable() {
@Override
public void run() {
runnable.run();
synchronized (tasks) {
tasks.remove(runnable);
}
}
};
executor.execute(wrapper);
}
public void cancel() {
synchronized (tasks) {
for (DelayedNotification task : tasks) {
task.cancel();
}
}
}
}
}